Skip to main content

GitHub Actions

Run the 1.0.0 Action in a trusted workflow. Private release access and the AppsCert workload identity are separate requirements.

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-read GitHub Environment in repository Settings and set its deployment-branch rule to the approved main branch. Verify the rule in GitHub Settings first, then store APPSCERT_RELEASE_READ_TOKEN as an Environment secret and the non-secret AppsCert settings as trusted Environment variables. Merely writing environment: appscert-release-read in 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 environment line and ask support to bind the environment as none. Configure APPSCERT_API_URL, APPSCERT_OIDC_AUDIENCE and APPSCERT_EXPECTED_SCOPES as 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.tgz

GitHub 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.

c3209c7