Before you start
The current client artifacts are published as versioned GitHub Release assets in private repositories. Sign in with a GitHub account that has repository read access before opening a release. An anonymous 404 is expected for these private links; it does not mean the release is missing. The SDKs are not published to npm or RubyGems, and the Action is not published to the GitHub Marketplace.
If you need repository access, email AppsCert support with the artifact and version you need and the GitHub account that should receive read access. Do not include API keys, OIDC assertions, workload tokens, passwords, private keys or certificate files in the request.
This guide covers the CI Action, not the GitHub repository connector. Complete the credential and binding checks below before running the workflow.
GitHub Action · 1.0.0
Open the private Action release. The complete file below runs on push to main or workflow_dispatch. GitHub lets a manual dispatch select a branch or tag (see workflow_dispatch); the job-level condition also requires refs/heads/main and an allowed event; a non-main run is skipped before a runner starts. This condition prevents accidental use of another ref, but is not a security boundary against someone who can change this workflow. The official Actions remain pinned to immutable release commit SHAs.
Protect credentials and bind the workload
Protect the private-release read credential before using this example. APPSCERT_RELEASE_READ_TOKEN is consumed to download private release assets before the AppsCert OIDC exchange. The AppsCert binding is checked only during that later exchange; it does not protect this GitHub credential. The read token must be authorized for both private release repositories, and the default GITHUB_TOKEN cannot read other private repositories.
- Preferred when supported: before storing the token, create the
appscert-release-readGitHub Environment in repository Settings and set its deployment-branch rule to the approvedmainbranch. Verify the rule in GitHub Settings first, then storeAPPSCERT_RELEASE_READ_TOKENas an Environment secret and the non-secret AppsCert settings as trusted Environment variables. Merely writingenvironment: appscert-release-readin YAML creates a missing Environment without protection rules or secrets; the name alone is not protection. - GitHub Free does not provide configurable Environments or Environment secrets for private repositories. For private repositories, Environment and deployment-branch controls depend on the plan; GitHub documents branch restrictions for Pro/Team and Environment secrets for Pro/Team/Enterprise. Required reviewers on Free/Pro/Team are available only for public repositories, so do not assume a private approval gate exists. Check the exact repository plan and Settings before using an Environment. If the needed private protections are unavailable, use a dedicated private trusted automation repository instead. In that fallback, remove the job's
environmentline and ask support to bind the environment asnone. ConfigureAPPSCERT_API_URL,APPSCERT_OIDC_AUDIENCEandAPPSCERT_EXPECTED_SCOPESas trusted repository variables, not event inputs. Store a repository secret only if every user with write access is trusted to access the private assets; GitHub documents that write access can expose repository secrets. If that cannot be guaranteed, do not run this workflow. See GitHub's Environment availability, deployment-branch rules and secret-use guidance.
Keep the workflow definition on protected main with reviewed changes, and do not add pull-request triggers or check out and execute untrusted PR code in this job. A workflow condition cannot protect a secret from a hostile workflow definition; people who can bypass review or branch rules must also be trusted with the credential. For the managed binding, provide the exact repository and numeric IDs, ref refs/heads/main, workflow reference OWNER/REPOSITORY/.github/workflows/appscert-plan.yml@refs/heads/main, environment (appscert-release-read or none only for the stated fallback), audience and approved scopes. Do not request wildcard refs or workflow paths; support must confirm the exact binding and audience before enabling the exchange. See managed workload onboarding.
Run the plan workflow
name: AppsCert private release plan
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
appscert-plan:
# This job guard is not a boundary against an untrusted workflow change.
if: >-
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment: appscert-release-read
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download and verify AppsCert releases
shell: bash
env:
GH_TOKEN: ${{ secrets.APPSCERT_RELEASE_READ_TOKEN }}
run: |
set -euo pipefail
gh auth status
action_dir="$RUNNER_TEMP/appscert-action"
packages_dir="$RUNNER_TEMP/appscert-packages"
mkdir -p "$action_dir" "$packages_dir"
verify_sha256sums() {
local dir="$1"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$dir" && sha256sum -c SHA256SUMS)
elif command -v shasum >/dev/null 2>&1; then
(cd "$dir" && shasum -a 256 -c SHA256SUMS)
else
echo "Need sha256sum or shasum to verify release assets" >&2
return 1
fi
}
gh release download 1.0.0 --repo jojoyaco/appscert-github-action --pattern 'appscert-github-action-1.0.0.tar.gz' --pattern SHA256SUMS --dir "$action_dir"
gh release download 1.0.0 --repo jojoyaco/appscert-client-js --pattern 'appscert-sdk-1.0.0.tgz' --pattern 'appscert-1.0.0.tgz' --pattern SHA256SUMS --dir "$packages_dir"
verify_sha256sums "$action_dir"
verify_sha256sums "$packages_dir"
tar -xzf "$action_dir/appscert-github-action-1.0.0.tar.gz" -C "$GITHUB_WORKSPACE"
- name: Exchange the pre-approved GitHub identity
id: exchange
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
APPSCERT_API_URL: ${{ vars.APPSCERT_API_URL }}
APPSCERT_OIDC_AUDIENCE: ${{ vars.APPSCERT_OIDC_AUDIENCE }}
APPSCERT_EXPECTED_SCOPES: ${{ vars.APPSCERT_EXPECTED_SCOPES }}
with:
script: |
const env = process.env;
const officialIssuer = 'https://token.actions.githubusercontent.com';
const approvedApiOrigin = 'https://appscert.com';
function fail() {
throw new Error('The approved workload exchange could not be validated.');
}
function parseScopes(value) {
if (typeof value !== 'string' || value.length === 0) return null;
const values = value.split(',');
if (values.some((scope) => !scope || scope.trim() !== scope)) return null;
if (new Set(values).size !== values.length) return null;
return values.sort();
}
try {
if (!['push', 'workflow_dispatch'].includes(context.eventName)) fail();
if (!context.repo.owner || !context.repo.repo) fail();
const repository = context.repo.owner + '/' + context.repo.repo;
const audience = env.APPSCERT_OIDC_AUDIENCE;
const expectedScopes = parseScopes(env.APPSCERT_EXPECTED_SCOPES);
if (!audience || audience.length > 256 || /[\r\n]/.test(audience) || !expectedScopes) fail();
const apiBase = new URL(env.APPSCERT_API_URL || '');
if (apiBase.origin !== approvedApiOrigin || apiBase.pathname !== '/' || apiBase.search || apiBase.hash || apiBase.username || apiBase.password) fail();
const apiEndpoint = new URL('/api/v1/auth/exchange', approvedApiOrigin);
// The official toolkit uses the runner-provided OIDC URL and masks this JWT.
const assertion = await core.getIDToken(audience);
if (typeof assertion !== 'string' || assertion.length > 8192 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(assertion)) fail();
const exchangeResponse = await fetch(apiEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ assertion }),
redirect: 'error',
signal: AbortSignal.timeout(10_000),
});
if (!exchangeResponse.ok) fail();
const result = await exchangeResponse.json();
const token = result && result.access_token;
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) fail();
if (result.token_type !== 'Bearer' || !Number.isSafeInteger(result.expires_in) || result.expires_in < 1 || result.expires_in > 3600) fail();
if (result.actor !== 'workload' || result.issuer !== officialIssuer || result.repository !== repository) fail();
const actualScopes = parseScopes(result.scope);
if (!actualScopes || JSON.stringify(actualScopes) !== JSON.stringify(expectedScopes)) fail();
// Mask the AppsCert credential before exporting it to later steps.
core.setSecret(token);
core.setOutput('token', token);
} catch {
core.setFailed('AppsCert OIDC exchange failed; no token was exported.');
}
- name: Create an AppsCert plan
uses: ./appscert-github-action-1.0.0
with:
mode: plan
api-url: ${{ vars.APPSCERT_API_URL }}
workload-token: ${{ steps.exchange.outputs.token }}
input-file: inputs/plan.json
cli-package: ${{ runner.temp }}/appscert-packages/appscert-1.0.0.tgz
sdk-package: ${{ runner.temp }}/appscert-packages/appscert-sdk-1.0.0.tgzGitHub Actions external access for this private repository is set to none: do not use uses: jojoyaco/appscert-github-action@1.0.0 from an arbitrary repository. The Action still accepts an already-issued workload token; the preceding workflow step performs the exchange and exports only its masked short-lived token. The exchange does not register an identity. The example uses the official core.getIDToken(audience) toolkit API, which uses the runner-provided OIDC request URL and masks the returned JWT; it does not guess or pin an OIDC host. GitHub documents that id-token: write only permits requesting a token; it does not grant repository write access. The AppsCert request remains fixed to https://appscert.com, rejects redirects and times out after 10 seconds. For a managed non-production deployment, change the API-origin pin only after verifying the destination; do not derive it from event data. See the official OIDC reference, Actions toolkit OIDC API, github-script v9.0.0 release, masking guidance and step-output docs. The checkout v7.0.1 release is pinned above. See managed workload onboarding.