diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml new file mode 100644 index 0000000..f56a07e --- /dev/null +++ b/.github/workflows/lint-workflows.yml @@ -0,0 +1,66 @@ +name: Lint workflows + +on: + merge_group: + push: + branches: + - main + paths: + - .github/workflows/** + pull_request: + # Recommended by Graphite: https://graphite.dev/docs/github-configuration-guidelines#github-actions + types: [opened, synchronize, reopened] + paths: + - .github/workflows/** + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: actionlint + zizmor + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + astral.sh:443 + pypi.org:443 + files.pythonhosted.org:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + registry-1.docker.io:443 + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: actionlint + uses: docker://rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 # 1.7.12 + with: + args: -color + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: false + + # Gate on medium-and-higher findings at every confidence level. + - name: zizmor + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: uvx zizmor==1.26.1 --min-severity=medium .github/workflows/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..82205b2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,317 @@ +name: Release tags + +# Surface the selected ref and dry-run state in the run list and in the +# protected-environment approval notification. The version is inferred from +# types.go and shown in the preflight summary before approval. +run-name: "Release tags from ${{ github.ref_name }}${{ inputs.dry-run && ' (dry run)' || '' }}" + +on: + workflow_dispatch: + inputs: + dry-run: + default: true + description: Validate the release and run git push --dry-run without creating remote tags. + type: boolean + +permissions: + # The workflow token never writes repository contents. The gated job mints a + # short-lived, repository-scoped GitHub App token for the tag push. + contents: read + +# Never race two releases. Queue rather than cancel so a run that has passed +# the approval gate can finish creating both module tags atomically. +concurrency: + group: release-tags + cancel-in-progress: false + +jobs: + # Runs before approval so an environment reviewer can inspect a completed + # preflight and its step summary before allowing access to the App key. + preflight: + name: Preflight + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + rampart-tag: ${{ steps.release.outputs.rampart-tag }} + root-tag: ${{ steps.release.outputs.root-tag }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Harden runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + proxy.golang.org:443 + sum.golang.org:443 + storage.googleapis.com:443 + vuln.go.dev:443 + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: 1.25.x + check-latest: true + cache-dependency-path: | + go.sum + tools/go.sum + sensitiveinfo/rampart/go.sum + + - name: Assert valid release request and matching versions + id: release + env: + DRY_RUN: ${{ inputs.dry-run }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + set -euo pipefail + + case "$DRY_RUN" in + true|false) ;; + *) + echo "::error::Invalid dry-run value; expected 'true' or 'false'." + exit 1 + ;; + esac + + if [[ "$REF_TYPE" != "branch" || "$REF_NAME" != "main" ]]; then + echo "::error::Dispatch this workflow from the main branch, not '$REF_NAME' ($REF_TYPE)." + exit 1 + fi + + version="$(sed -nE 's/^const Version = "([^"]+)"$/\1/p' types.go)" + semver_pattern='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' + if ! [[ "$version" =~ $semver_pattern ]]; then + echo "::error file=types.go::Version '$version' must be canonical SemVer without a leading 'v' or build metadata." + exit 1 + fi + + expected="v$version" + mismatched="" + check_requirement() { + local file="$1" + local module="$2" + local actual + actual="$(go mod edit -json "$file" | jq -r --arg module "$module" '.Require[] | select(.Path == $module) | .Version')" + if [[ "$actual" != "$expected" ]]; then + echo "::error file=$file::$module is '${actual:-}', expected '$expected'." + mismatched="true" + fi + } + + check_requirement sensitiveinfo/rampart/go.mod github.com/arcjet/arcjet-go + check_requirement examples/nethttp/go.mod github.com/arcjet/arcjet-go + check_requirement examples/nethttp/go.mod github.com/arcjet/arcjet-go/sensitiveinfo/rampart + if [[ -n "$mismatched" ]]; then + exit 1 + fi + + { + echo "version=$version" + echo "root-tag=v$version" + echo "rampart-tag=sensitiveinfo/rampart/v$version" + } >> "$GITHUB_OUTPUT" + + - name: Assert release tags do not exist + env: + RAMPART_TAG: ${{ steps.release.outputs.rampart-tag }} + ROOT_TAG: ${{ steps.release.outputs.root-tag }} + run: | + set -euo pipefail + for tag in "$ROOT_TAG" "$RAMPART_TAG"; do + if output="$(git ls-remote --exit-code --tags origin "refs/tags/$tag" 2>&1)"; then + echo "$output" + echo "::error::Tag '$tag' already exists. Release tags must never be moved." + exit 1 + else + status=$? + if [[ "$status" -ne 2 ]]; then + echo "$output" + echo "::error::Could not check whether tag '$tag' exists." + exit "$status" + fi + fi + done + + - name: Verify modules are tidy + run: | + set -euo pipefail + go mod tidy + go -C tools mod tidy + go -C sensitiveinfo/rampart mod tidy + files=( + go.mod + go.sum + tools/go.mod + tools/go.sum + sensitiveinfo/rampart/go.mod + sensitiveinfo/rampart/go.sum + ) + if [[ -n "$(git status --porcelain -- "${files[@]}")" ]]; then + echo "::error::Go modules are not tidy. Run 'just tidy' and commit the changes." + git --no-pager diff -- "${files[@]}" + exit 1 + fi + + - name: Run release checks + run: | + set -euo pipefail + go tool -modfile=tools/go.mod golangci-lint run ./... + go -C sensitiveinfo/rampart tool -modfile="${GITHUB_WORKSPACE}/tools/go.mod" golangci-lint run ./... + go tool -modfile=tools/go.mod govulncheck ./... + go -C sensitiveinfo/rampart tool -modfile="${GITHUB_WORKSPACE}/tools/go.mod" govulncheck ./... + go build ./... + go test -race -shuffle=on ./... + go -C sensitiveinfo/rampart build ./... + go -C sensitiveinfo/rampart test -shuffle=on -skip '^TestAdversarialConcurrentDetect$' ./... + go -C sensitiveinfo/rampart test -race -run '^TestAdversarialConcurrentDetect$' ./... + go -C examples/nethttp test ./... + + - name: Summarize the release request + env: + DRY_RUN: ${{ inputs.dry-run }} + RAMPART_TAG: ${{ steps.release.outputs.rampart-tag }} + ROOT_TAG: ${{ steps.release.outputs.root-tag }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + { + echo "## Release request" + echo "- version: \`$VERSION\`" + echo "- root module tag: \`$ROOT_TAG\`" + echo "- Rampart module tag: \`$RAMPART_TAG\`" + echo "- ref: \`$GITHUB_REF_NAME\` (\`$GITHUB_SHA\`)" + echo "- dry run: \`$DRY_RUN\`" + echo + echo "All version, module, lint, vulnerability, build, and test checks passed." + } >> "$GITHUB_STEP_SUMMARY" + + release: + name: Create ${{ needs.preflight.outputs.root-tag }} release tags + needs: preflight + runs-on: ubuntu-24.04 + environment: + name: release-tags + permissions: + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + objects.githubusercontent.com:443 + proxy.golang.org:443 + + - name: Checkout the preflighted commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + + - name: Create and push annotated tags + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + DRY_RUN: ${{ inputs.dry-run }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RAMPART_TAG: ${{ needs.preflight.outputs.rampart-tag }} + ROOT_TAG: ${{ needs.preflight.outputs.root-tag }} + TARGET_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + gh auth setup-git + + git tag --annotate "$ROOT_TAG" --message "$ROOT_TAG" "$TARGET_SHA" + git tag --annotate "$RAMPART_TAG" --message "$RAMPART_TAG" "$TARGET_SHA" + + push_args=(--atomic) + if [[ "$DRY_RUN" == "true" ]]; then + push_args+=(--dry-run) + fi + git push "${push_args[@]}" origin \ + "refs/tags/$ROOT_TAG:refs/tags/$ROOT_TAG" \ + "refs/tags/$RAMPART_TAG:refs/tags/$RAMPART_TAG" + + - name: Warm Go module proxy and request pkg.go.dev indexing + if: ${{ !inputs.dry-run }} + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + expected="v$VERSION" + check_module() { + local module="$1" + local info + if ! info="$(curl \ + --fail \ + --retry 12 \ + --retry-all-errors \ + --retry-delay 10 \ + --show-error \ + --silent \ + "https://proxy.golang.org/$module/@v/$expected.info")"; then + echo "::error::The tags were published, but proxy.golang.org did not resolve '$module@$expected'." + echo "::notice::Do not delete or repoint either tag. Retry with: GOPROXY=https://proxy.golang.org go list -m '$module@$expected'" + return 1 + fi + if ! jq -e --arg expected "$expected" '.Version == $expected' <<< "$info" > /dev/null; then + echo "$info" + echo "::error::The tags were published, but the Go module proxy returned an unexpected version for '$module'." + echo "::notice::Do not delete or repoint either tag. Retry with: GOPROXY=https://proxy.golang.org go list -m '$module@$expected'" + return 1 + fi + echo "Available from proxy.golang.org: $module@$expected" + } + + check_module github.com/arcjet/arcjet-go + check_module github.com/arcjet/arcjet-go/sensitiveinfo/rampart + + - name: Summarize the result + env: + DRY_RUN: ${{ inputs.dry-run }} + RAMPART_TAG: ${{ needs.preflight.outputs.rampart-tag }} + ROOT_TAG: ${{ needs.preflight.outputs.root-tag }} + VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + if [[ "$DRY_RUN" == "true" ]]; then + result="Dry run completed; no remote tags were created." + else + result="Created both remote tags atomically and made both versions available from proxy.golang.org." + fi + { + echo "## Release result" + echo "$result" + echo + echo "- \`$ROOT_TAG\`" + echo "- \`$RAMPART_TAG\`" + echo "- commit: \`$GITHUB_SHA\`" + if [[ "$DRY_RUN" == "false" ]]; then + echo "- pkg.go.dev: https://pkg.go.dev/github.com/arcjet/arcjet-go@v$VERSION" + echo "- Rampart docs: https://pkg.go.dev/github.com/arcjet/arcjet-go/sensitiveinfo/rampart@v$VERSION" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc726ec..5d5c2bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,10 @@ the public compatibility floor at Go 1.25.0. Action versions are pinned by commit SHA and the runner is locked down with `step-security/harden-runner` in egress-block mode. +Changes under `.github/workflows/` also run +[`.github/workflows/lint-workflows.yml`](.github/workflows/lint-workflows.yml), +which gates on `actionlint` and medium-or-higher `zizmor` findings. + ## Releasing The SDK and optional Rampart backend are released from the same commit at the @@ -125,22 +129,17 @@ module in a repository subdirectory needs that subdirectory in its tag. and reachable-vulnerability checks for both published modules. 5. Review the exported API changes since the previous release. Once v1 is published, incompatible API changes require a new major module version. -6. Merge the release PR to `main`, then create both annotated tags on the exact - merge commit. The suffix of both tags must match `Version` exactly, including - any prerelease suffix. For example, for version `1.2.3`: - - ```sh - git tag -a v1.2.3 -m v1.2.3 - git tag -a sensitiveinfo/rampart/v1.2.3 \ - -m sensitiveinfo/rampart/v1.2.3 - ``` - -7. Push the root tag first, followed by the optional backend tag: - - ```sh - git push origin v1.2.3 - git push origin sensitiveinfo/rampart/v1.2.3 - ``` +6. Merge the release PR to `main`, then run the + [Release tags workflow](.github/workflows/release.yml) from `main`. The + workflow infers the version from `types.go`, verifies every lockstep version, + and creates both annotated module tags on the exact commit. Use its dry-run + mode for a rehearsal. +7. Review the completed preflight summary, then approve the protected + `release-tags` environment. The gated job pushes both tags atomically using + the release GitHub App and requests both versions from the Go module proxy + so pkg.go.dev discovers them. See the + [release automation runbook](docs/RELEASING.md) for the one-time App, + environment, and tag-ruleset configuration. 8. Verify the public module graph from a fresh temporary module. Do not add local `replace` directives to this smoke test: diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..3ea4ff1 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,146 @@ +# Release automation + +The [Release tags workflow](../.github/workflows/release.yml) creates the two +tags that publish arcjet-go through the Go module proxy: + +- `v` for `github.com/arcjet/arcjet-go` +- `sensitiveinfo/rampart/v` for the optional Rampart module + +Both are annotated tags on the same commit. The workflow pushes them atomically, +so a release cannot leave only one of the two module tags behind. + +The workflow has two jobs. The ungated preflight validates `main`, checks every +version and dependency pin, confirms both tags are unused, runs the release +checks, and writes the exact request to the workflow summary. Only then can a +reviewer approve the `release-tags` environment. The gated job mints a +short-lived GitHub App token and pushes the tags. + +Every workflow change is checked by `actionlint` and `zizmor` in the +[workflow lint job](../.github/workflows/lint-workflows.yml). Actions are pinned +to immutable commit SHAs or container digests, checkout credentials are not +persisted, and the release jobs use explicit minimum permissions. + +## One-time GitHub configuration + +Configure all three controls before running the workflow. The workflow's normal +`GITHUB_TOKEN` is read-only and cannot substitute for the GitHub App. + +### 1. GitHub App + +Create an organization-owned GitHub App dedicated to releases: + +1. Disable webhooks; this App does not receive events. +2. Grant the repository permission **Contents: Read and write**. Do not grant + organization or account permissions. +3. Install it only on `arcjet/arcjet-go`. +4. Generate a private key and record the App's client ID. + +The contents permission is the narrowest GitHub App permission that can create +Git tags. Repository rules, rather than a broader App permission, limit which +actors can create release tags. + +### 2. Protected environment + +In `arcjet/arcjet-go`, open **Settings -> Environments** and create an +environment named `release-tags`: + +1. Add the release approvers under **Required reviewers**. +2. Enable **Prevent self-review**. +3. Disable **Allow administrators to bypass configured protection rules**. +4. Under **Deployment branches and tags**, select **Selected branches and + tags**, then allow only the `main` branch. +5. Add environment variable `RELEASE_APP_CLIENT_ID` with the App client ID. +6. Add environment secret `RELEASE_APP_PRIVATE_KEY` with the complete PEM + private key. + +Environment secrets are unavailable to the workflow until the protection rules +pass. Keeping the App key here, rather than as a repository secret, is what +makes approval a credential boundary. + +### 3. Release-tag ruleset + +Open **Settings -> Rules -> Rulesets**, create a new **tag ruleset**, and use: + +- Name: `release-tags` +- Enforcement status: **Active** +- Target tags, included by pattern: + - `v*` + - `sensitiveinfo/rampart/v*` +- Bypass list: only the release GitHub App, set to **Always allow** +- Tag protections: + - **Restrict creations** + - **Restrict updates** + - **Restrict deletions** + - **Block force pushes** + +Do not add administrators, organization owners, roles, teams, users, or +`github-actions` to the bypass list. The two explicit patterns protect the +published modules without accidentally treating every tag containing a `v` as +a release. If another submodule is published later, add its exact +`/v*` pattern. + +The GitHub App is allowed to bypass every rule in this ruleset because GitHub +does not offer per-rule bypasses. The workflow only creates new tags and refuses +to proceed when either tag already exists; the App key remains behind the +environment approval gate. + +## Running a release + +1. Merge the release preparation PR to `main`. `Version` in `types.go`, the + Rampart SDK requirement, and both example requirements must all contain the + same version. +2. Open **Actions -> Release tags -> Run workflow**. +3. Select `main` and choose whether this is a dry run. The workflow infers the + version from `types.go`. +4. Open the completed **Preflight** job and review its workflow summary. It + shows both proposed tags, the exact commit, the inferred version, and the + dry-run state. +5. Approve or reject the `release-tags` environment. + +A dry run creates both annotated tags only in the ephemeral runner and performs +`git push --dry-run` with the GitHub App token. This validates the preflight, +environment, secret, App installation, token permission, Git authentication, +and push refspecs without changing the repository. A dry-run push sends no ref +update, so it cannot exercise the tag ruleset or prove that the App has bypass +permission. Review the active ruleset before the first real release; that push +is the first end-to-end test of the bypass configuration. + +The real run creates both remote tags atomically, then requests each exact +version from `proxy.golang.org`. That request warms the Go module mirror and +causes the version to be added to the index that pkg.go.dev monitors. New +documentation normally appears on pkg.go.dev within a few minutes. Do not +delete or repoint a published release tag; fix the problem and release a new +version instead. + +If the tag push succeeds but the proxy step fails, the release tags are already +published and correct even though the workflow is red. Do not delete or repoint +them, and do not rerun the workflow (preflight will correctly reject the +existing tags). Run the smoke test below; its `go get` requests retry discovery +through `proxy.golang.org` and complete the pkg.go.dev indexing trigger. + +## After tagging + +Verify both public modules from a fresh temporary module, without local +`replace` directives: + +```sh +VERSION=1.2.3 +SMOKE_DIR="$(mktemp -d)" +cd "$SMOKE_DIR" +go mod init example.com/arcjet-release-smoke +GOPROXY=https://proxy.golang.org go get \ + "github.com/arcjet/arcjet-go@v${VERSION}" \ + "github.com/arcjet/arcjet-go/sensitiveinfo/rampart@v${VERSION}" +go mod download all +``` + +Create one GitHub release for the root tag. Mention that the optional Rampart +backend was released in lockstep; do not create a second GitHub release for its +module-qualified tag. + +## References + +- [GitHub: Managing environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) +- [GitHub: Creating rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository) +- [GitHub: Available rules for rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets) +- [Go modules: Mapping versions to commits](https://go.dev/ref/mod#vcs-version)