From 938e5df5df0f9ac8b7fc6e63f8a15848d434d1ff Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Thu, 15 Oct 2026 07:53:01 +0200 Subject: [PATCH 1/3] feat(DOPS-3023): harden contract release workflows --- .github/actions/foundry/action.yml | 2 +- .github/workflows/foundry-ci.yml | 2 +- .github/workflows/hardhat-ci.yml | 2 +- .github/workflows/publish.yml | 80 ++++++++++++++++++++++++++---- .github/workflows/release.yml | 2 +- .github/workflows/tag.yml | 4 +- README.md | 39 +++++++++------ RELEASE_FLOW.md | 2 + test/release-workflows.sh | 79 +++++++++++++++++++++++++---- 9 files changed, 171 insertions(+), 41 deletions(-) diff --git a/.github/actions/foundry/action.yml b/.github/actions/foundry/action.yml index 1503615..a60db05 100644 --- a/.github/actions/foundry/action.yml +++ b/.github/actions/foundry/action.yml @@ -19,7 +19,7 @@ runs: using: composite steps: - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 with: version: ${{ inputs.version }} diff --git a/.github/workflows/foundry-ci.yml b/.github/workflows/foundry-ci.yml index 01632d0..596445b 100644 --- a/.github/workflows/foundry-ci.yml +++ b/.github/workflows/foundry-ci.yml @@ -111,6 +111,6 @@ jobs: run: yarn coverage - name: Upload to Codecov - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/hardhat-ci.yml b/.github/workflows/hardhat-ci.yml index a0ea98d..156e73d 100644 --- a/.github/workflows/hardhat-ci.yml +++ b/.github/workflows/hardhat-ci.yml @@ -85,6 +85,6 @@ jobs: run: yarn coverage - name: Upload to Codecov - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 035e6a9..dd55e7f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,20 +7,25 @@ on: description: Text in front of the package.json version in the tag, `v` for `v1.2.3` or empty for `1.2.3` type: string default: v + dry-run: + description: Run npm publish without publishing the package + type: boolean + default: false permissions: contents: read - id-token: write jobs: - npm: + build: runs-on: ubuntu-latest timeout-minutes: 30 - # The package's trusted publisher names this environment, which allows only v* tags. - environment: npm + permissions: + actions: read + contents: read steps: - uses: actions/checkout@v7 with: + fetch-depth: 0 persist-credentials: false - name: Check the release tag @@ -33,22 +38,77 @@ jobs: echo "::error::Run PUBLISH from the tag ${tag}, not from ${GITHUB_REF_NAME}" exit 1 fi + echo "VERSION=${version}" >> "$GITHUB_ENV" + + - name: Check the release branch + run: | + if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/release/${VERSION}"; then + echo "::error::Tag commit ${GITHUB_SHA} is not on release/${VERSION}" + exit 1 + fi + + - name: Check CI on the tag commit + env: + GH_TOKEN: ${{ github.token }} + run: | + ci=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${GITHUB_SHA}" --jq '[.workflow_runs[] | select(.name == "CI" and (.event == "push" or .event == "workflow_dispatch"))] | first | if . == null then "missing" else (.conclusion // .status) end') + if [ "$ci" != "success" ]; then + echo "::error::CI on ${GITHUB_SHA} is ${ci}. Run PUBLISH once CI has passed on this commit" + exit 1 + fi - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 - registry-url: https://registry.npmjs.org package-manager-cache: false - name: Install node modules run: yarn install --frozen-lockfile + - name: Pack + run: npm pack + + - name: Upload package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: npm-package + path: '*.tgz' + if-no-files-found: error + + npm: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm + permissions: + contents: read + id-token: write + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Download package + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 + with: + name: npm-package + path: package + - name: Publish - # npm publishes a pre-release only with an explicit --tag. + env: + DRY_RUN: ${{ inputs.dry-run }} run: | - if [[ "$(jq -r .version package.json)" == *-* ]]; then - npm publish --tag next - else - npm publish + package=$(find package -type f -name '*.tgz' -print -quit) + version=$(tar -xOf "$package" package/package.json | jq -r .version) + args=("$package") + if [[ "$version" == *-* ]]; then + args+=(--tag next) + fi + if [ "$DRY_RUN" = true ]; then + args+=(--dry-run) fi + npm publish "${args[@]}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5299dc..bb10baf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Create the GitHub release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 with: tag_name: ${{ env.TAG }} name: Release ${{ env.TAG }} diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 85f547c..f295727 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -37,9 +37,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - ci=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${GITHUB_SHA}&event=push" --jq '[.workflow_runs[] | select(.name == "CI")] | first | if . == null then "missing" else (.conclusion // .status) end') + ci=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${GITHUB_SHA}" --jq '[.workflow_runs[] | select(.name == "CI" and (.event == "push" or .event == "workflow_dispatch"))] | first | if . == null then "missing" else (.conclusion // .status) end') if [ "$ci" != "success" ]; then - echo "::error::CI on ${GITHUB_SHA} is ${ci}. Run TAG once CI has passed on this commit in main" + echo "::error::CI on ${GITHUB_SHA} is ${ci}. Run TAG once CI has passed on this commit" exit 1 fi diff --git a/README.md b/README.md index 952f573..0f946c7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Shared GitHub Actions workflows and the release flow of the 1inch contract repos ## Versions -Callers reference a release of this repository, such as `@v2.0.1`, and never a branch. A change merged here reaches a repository only when that repository merges a pull request that moves its callers to the release containing it. The change shows up as a commit in every repository, and moving the callers back reverts it. +Callers reference a release of this repository, such as `@v2.1.0`, and never a branch. A change merged here reaches a repository only when that repository merges a pull request that moves its callers to the release containing it. The change shows up as a commit in every repository, and moving the callers back reverts it. The release, publish and tag workflows run with `contents: write` or `id-token: write` in the calling repository. Because callers pin a release, a merge into `main` here does not change what can publish a package. Releases are immutable, so a published tag can be neither moved nor deleted, and a tag ruleset lets only repository admins create, move or delete `v*` tags, which covers a tag before its release is published. A caller references a tag only once its release is published. @@ -30,7 +30,7 @@ To ship a change: 3. Publish a release from `main`: a patch for a fix, a minor for a new input, a major for a change that callers have to adapt to. Immutable releases are enabled in this repository's settings, so the tag cannot move: ```bash - gh release create v2.0.2 --repo 1inch/ci-workflow-protocol --target main --generate-notes + gh release create v2.1.0 --repo 1inch/ci-workflow-protocol --target main --generate-notes ``` 4. In each repository, open a pull request that moves every caller to the new tag. Everything a repository references here, its callers and the actions alike, names the same release. @@ -46,10 +46,11 @@ name: CI on: pull_request: push: - branches: [main, 'release/*'] + branches: [main, 'release/**'] + workflow_dispatch: concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: @@ -57,7 +58,7 @@ permissions: jobs: ci: - uses: 1inch/ci-workflow-protocol/.github/workflows/hardhat-ci.yml@v2.0.1 + uses: 1inch/ci-workflow-protocol/.github/workflows/hardhat-ci.yml@v2.1.0 with: profile: default sources: contracts @@ -89,7 +90,7 @@ With `coverage: true`, the caller also passes the Codecov token: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} ``` -The checks are reported as `ci / test`, `ci / snapshot`, `ci / lint` and `ci / coverage`; require exactly the ones a repository runs, plus its own jobs, on `main` and `release/*`. Only the `coverage` job reads a secret. A failed upload does not fail the job, so pull requests from forks, which get no secrets, still pass CI. +The checks are reported as `ci / test`, `ci / snapshot`, `ci / lint` and `ci / coverage`; require exactly the ones a repository runs, plus its own jobs, on `main` and `release/**`. Only the `coverage` job reads a secret. A failed upload does not fail the job, so pull requests from forks, which get no secrets, still pass CI. ### Jobs of a repository's own @@ -98,7 +99,7 @@ A check that only one repository needs stays in that repository, as a plain job ```yaml jobs: ci: - uses: 1inch/ci-workflow-protocol/.github/workflows/hardhat-ci.yml@v2.0.1 + uses: 1inch/ci-workflow-protocol/.github/workflows/hardhat-ci.yml@v2.1.0 with: profile: default sources: contracts @@ -113,15 +114,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: 1inch/ci-workflow-protocol/.github/actions/setup@v2.0.1 + - uses: 1inch/ci-workflow-protocol/.github/actions/setup@v2.1.0 - - uses: 1inch/ci-workflow-protocol/.github/actions/foundry@v2.0.1 + - uses: 1inch/ci-workflow-protocol/.github/actions/foundry@v2.1.0 - name: Build the Foundry deployers run: yarn deployers:foundry && forge build ``` -The job runs on the caller's triggers, concurrency and read-only permissions, and reports as `forge-build` next to `ci / test` and the other shared checks. Require it on `main` and `release/*` like them. It reuses the shared setup and Foundry actions at the same release as the `ci` job, so it installs the same Foundry version as `foundry-ci.yml`. From another repository an action takes the full path and the tag, because `$/` would point at the calling repository. When a second repository needs the same check, it moves into the shared workflow as an optional input. +The job runs on the caller's triggers, concurrency and read-only permissions, and reports as `forge-build` next to `ci / test` and the other shared checks. Require it on `main` and `release/**` like them. It reuses the shared setup and Foundry actions at the same release as the `ci` job, so it installs the same Foundry version as `foundry-ci.yml`. From another repository an action takes the full path and the tag, because `$/` would point at the calling repository. When a second repository needs the same check, it moves into the shared workflow as an optional input. ## Releases @@ -142,7 +143,7 @@ permissions: jobs: tag: - uses: 1inch/ci-workflow-protocol/.github/workflows/tag.yml@v2.0.1 + uses: 1inch/ci-workflow-protocol/.github/workflows/tag.yml@v2.1.0 ``` | Input | Type | Default | Meaning | @@ -167,7 +168,7 @@ permissions: jobs: release: - uses: 1inch/ci-workflow-protocol/.github/workflows/release.yml@v2.0.1 + uses: 1inch/ci-workflow-protocol/.github/workflows/release.yml@v2.1.0 with: tag-prefix: v changelog: false @@ -191,21 +192,29 @@ on: permissions: contents: read + actions: read id-token: write jobs: publish-npmjs: - uses: 1inch/ci-workflow-protocol/.github/workflows/publish.yml@v2.0.1 + uses: 1inch/ci-workflow-protocol/.github/workflows/publish.yml@v2.1.0 ``` -The workflow runs only from the tag made of `tag-prefix` (`v` by default) and the `package.json` version, and fails without publishing when started from a branch or another tag. Packages go to npm only, not to GitHub Packages. +| Input | Type | Default | Meaning | +|---|---|---|---| +| `tag-prefix` | string | `v` | Text in front of the package.json version in the tag: `v` for `v1.2.3`, empty for `1.2.3` | +| `dry-run` | boolean | `false` | Build and validate the package, then run `npm publish --dry-run` | + +The workflow runs only from the tag made of `tag-prefix` (`v` by default) and the `package.json` version. The tag commit must belong to `release/` and have a successful `CI` run from `push` or `workflow_dispatch`. Packages go to npm only, not to GitHub Packages. A pre-release goes to the `next` dist-tag, and every other version becomes `latest`. The job runs in the calling repository's `npm` environment. Create it in the repository settings before the first run, because GitHub creates an unprotected environment on first use: - deployment branches and tags: only the tag pattern `v*`; -- required reviewers: the repository's maintainers, so every publish waits for an approval. +- required reviewers: none. + +Package installation and packing run in a separate build job with read-only repository and Actions permissions. The npm job receives only the packed tarball, has no checkout or install step, and alone gets `id-token: write`. npm publishing uses [trusted publishing](https://docs.npmjs.com/trusted-publishers/), so no npm token is stored anywhere. Before the first run, a package admin adds a trusted publisher in the package settings on npmjs.com: diff --git a/RELEASE_FLOW.md b/RELEASE_FLOW.md index fb40f9b..27905bd 100644 --- a/RELEASE_FLOW.md +++ b/RELEASE_FLOW.md @@ -59,6 +59,8 @@ flowchart LR A patch of a deployed version starts from its tag: cut `release/X.Y.(Z+1)` from `vX.Y.Z` and set `package.json` to `X.Y.(Z+1)` in its first pull request. Then the patch goes to audit and through steps 3 to 7 like any release. +To bring an existing branch into this flow, create `release/` from its head. Open a pull request that adds the current CI, TAG, CREATE_RELEASE and PUBLISH callers. Merge that pull request into the release branch before tagging. + ## Deployment records Each deploy records `deployments//vX.Y.Z.json`: addresses, tx hashes, block numbers, git SHA, deployer, toolchain version, verification metadata. The records reach `main` through a pull request and are not committed to the release branch, so its head, and the tag on it, stays the deployed commit. diff --git a/test/release-workflows.sh b/test/release-workflows.sh index 2b773fb..41165b1 100755 --- a/test/release-workflows.sh +++ b/test/release-workflows.sh @@ -23,7 +23,16 @@ while [ $# -gt 0 ]; do esac done case "$path" in - *actions/runs*) jq -r "${jq_expr:-.}" "$SIM_RUNS" ;; + *actions/runs*) + head_sha=${path#*head_sha=} + if [ "$head_sha" = "$path" ]; then + head_sha= + else + head_sha=${head_sha%%&*} + fi + jq --arg head_sha "$head_sha" '{"workflow_runs":[.workflow_runs[] | select($head_sha == "" or .head_sha == $head_sha)]}' "$SIM_RUNS" | + jq -r "${jq_expr:-.}" + ;; */git/refs) if [ "$SIM_TAG_EXISTS" = 1 ]; then echo "gh: Reference already exists (HTTP 422)" >&2 @@ -36,22 +45,36 @@ cat > "$sim/bin/npm" <<'EOF' #!/usr/bin/env bash echo "npm $*" >> "$SIM_LOG" EOF -chmod +x "$sim/bin/gh" "$sim/bin/npm" +cat > "$sim/bin/git" <<'EOF' +#!/usr/bin/env bash +echo "git $*" >> "$SIM_LOG" +if [ "$1" = merge-base ] && [ "$SIM_ON_BRANCH" != 1 ]; then + exit 1 +fi +EOF +cat > "$sim/bin/yarn" <<'EOF' +#!/usr/bin/env bash +echo "yarn $*" >> "$SIM_LOG" +EOF +chmod +x "$sim/bin/gh" "$sim/bin/npm" "$sim/bin/git" "$sim/bin/yarn" failures=0 tag_exists=0 +on_branch=1 +dry_run=false ci_run() { + local result=$1 event=${2:-push} sha=${3:-abc1234} case "$1" in none) echo '{"workflow_runs":[]}' ;; - in_progress) echo '{"workflow_runs":[{"name":"CI","status":"in_progress","conclusion":null}]}' ;; - *) printf '{"workflow_runs":[{"name":"CI","status":"completed","conclusion":"%s"}]}\n' "$1" ;; + in_progress) printf '{"workflow_runs":[{"name":"CI","event":"%s","head_sha":"%s","status":"in_progress","conclusion":null}]}\n' "$event" "$sha" ;; + *) printf '{"workflow_runs":[{"name":"CI","event":"%s","head_sha":"%s","status":"completed","conclusion":"%s"}]}\n' "$event" "$sha" "$result" ;; esac > "$sim/runs.json" } step_script() { ruby -ryaml -e ' - steps = YAML.load_file(ARGV[0])["jobs"].values.first["steps"] + steps = YAML.load_file(ARGV[0])["jobs"].values.flat_map { |job| job["steps"] || [] } step = steps.find { |s| s["name"] == ARGV[1] } or abort print step["run"] ' "$1" "$2" @@ -64,10 +87,14 @@ run_steps() { local wd wd=$(mktemp -d "$sim/wd.XXXX") printf '{"name":"@1inch/solidity-utils","version":"%s"}\n' "$version" > "$wd/package.json" + mkdir -p "$wd/package/package" + cp "$wd/package.json" "$wd/package/package/package.json" + tar -czf "$wd/package/package.tgz" -C "$wd/package" package + rm -r "$wd/package/package" : > "$wd/env" : > "$sim/calls" ( - export PATH="$sim/bin:$PATH" SIM_LOG="$sim/calls" SIM_RUNS="$sim/runs.json" SIM_TAG_EXISTS=$tag_exists + export PATH="$sim/bin:$PATH" SIM_LOG="$sim/calls" SIM_RUNS="$sim/runs.json" SIM_TAG_EXISTS=$tag_exists SIM_ON_BRANCH=$on_branch DRY_RUN=$dry_run export GITHUB_REPOSITORY=1inch/solidity-utils GITHUB_SHA=abc1234 GH_TOKEN=stub TAG_PREFIX=v export GITHUB_REF=$ref GITHUB_REF_NAME=${ref#refs/*/} GITHUB_ENV="$wd/env" GITHUB_OUTPUT="$wd/output" for step in "$@"; do @@ -112,15 +139,33 @@ else failures=$((failures + 1)) fi +npm_has_install=$(ruby -ryaml -e ' + steps = YAML.load_file(ARGV[0])["jobs"]["npm"]["steps"] + found = steps.any? { |step| step["name"].to_s.match?(/install/i) || step["run"].to_s.match?(/\b(yarn install|npm (ci|install))\b/) } + print found +' "$workflows/publish.yml") +if [ "$npm_has_install" = false ]; then + echo "ok PUBLISH npm job has no install step" +else + echo "FAIL PUBLISH npm job has an install step" + failures=$((failures + 1)) +fi + tag_steps=("Check the release branch" "Check CI on the branch head" "Tag the version in package.json") tagged='git/refs -f ref=refs/tags/v7\.0\.1 ' ci_run success -check "TAG from release/7.0.1 after CI passed on main" ok "$tagged" tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" +check "TAG from release/7.0.1 after push CI passed" ok "$tagged" tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" +ci_run success workflow_dispatch +check "TAG from release/7.0.1 after dispatched CI passed" ok "$tagged" tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" +ci_run success pull_request +check "TAG ignores pull request CI" "Check CI on the branch head" - tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" +ci_run success push other123 +check "TAG ignores CI from another commit" "Check CI on the branch head" - tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" check "TAG from main" "Check the release branch" - tag.yml 7.0.1 refs/heads/main "${tag_steps[@]}" check "TAG from release/7.0.2 while package.json says 7.0.1" "Check the release branch" - tag.yml 7.0.1 refs/heads/release/7.0.2 "${tag_steps[@]}" ci_run failure -check "TAG after CI failed on main" "Check CI on the branch head" - tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" +check "TAG after CI failed" "Check CI on the branch head" - tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" ci_run in_progress check "TAG while CI still runs" "Check CI on the branch head" - tag.yml 7.0.1 refs/heads/release/7.0.1 "${tag_steps[@]}" ci_run none @@ -134,8 +179,22 @@ check "CREATE_RELEASE from v7.0.1" ok - release.yml 7.0.1 refs/tags/v7.0.1 "Find check "CREATE_RELEASE from release/7.0.1" "Find the release tag" - release.yml 7.0.1 refs/heads/release/7.0.1 "Find the release tag" check "CREATE_RELEASE from v7.0.0 while package.json says 7.0.1" "Find the release tag" - release.yml 7.0.1 refs/tags/v7.0.0 "Find the release tag" -check "PUBLISH from v7.0.1" ok '^npm publish $' publish.yml 7.0.1 refs/tags/v7.0.1 "Check the release tag" "Publish" -check "PUBLISH a pre-release from v7.1.0-rc.1" ok '^npm publish --tag next $' publish.yml 7.1.0-rc.1 refs/tags/v7.1.0-rc.1 "Check the release tag" "Publish" +publish_build_steps=("Check the release tag" "Check the release branch" "Check CI on the tag commit") +ci_run success +on_branch=1 +check "PUBLISH build from tested release tag" ok - publish.yml 7.0.1 refs/tags/v7.0.1 "${publish_build_steps[@]}" +on_branch=0 +check "PUBLISH rejects tag off release branch" "Check the release branch" - publish.yml 7.0.1 refs/tags/v7.0.1 "${publish_build_steps[@]}" +on_branch=1 +ci_run failure +check "PUBLISH rejects tag without passed CI" "Check CI on the tag commit" - publish.yml 7.0.1 refs/tags/v7.0.1 "${publish_build_steps[@]}" +ci_run success workflow_dispatch +check "PUBLISH accepts dispatched CI" ok - publish.yml 7.0.1 refs/tags/v7.0.1 "${publish_build_steps[@]}" +check "PUBLISH from v7.0.1" ok '^npm publish package/package\.tgz $' publish.yml 7.0.1 refs/tags/v7.0.1 "Publish" +check "PUBLISH a pre-release from v7.1.0-rc.1" ok '^npm publish package/package\.tgz --tag next $' publish.yml 7.1.0-rc.1 refs/tags/v7.1.0-rc.1 "Publish" +dry_run=true +check "PUBLISH dry-run" ok '^npm publish package/package\.tgz --dry-run $' publish.yml 7.0.1 refs/tags/v7.0.1 "Publish" +dry_run=false check "PUBLISH from main" "Check the release tag" - publish.yml 7.0.1 refs/heads/main "Check the release tag" "Publish" if [ "$failures" -gt 0 ]; then From 13cd9dea933b51b1ec13dd57a10a74efb206fc53 Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Thu, 15 Oct 2026 08:07:18 +0200 Subject: [PATCH 2/3] fix(DOPS-3023): protect npm publish authority --- .github/workflows/publish.yml | 4 ++-- README.md | 2 +- test/release-workflows.sh | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dd55e7f..86f5938 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,7 +58,7 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: node-version: 24 package-manager-cache: false @@ -86,7 +86,7 @@ jobs: id-token: write steps: - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: node-version: 24 registry-url: https://registry.npmjs.org diff --git a/README.md b/README.md index 0f946c7..36eb05c 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ A pre-release goes to the `next` dist-tag, and every other version becomes `late The job runs in the calling repository's `npm` environment. Create it in the repository settings before the first run, because GitHub creates an unprotected environment on first use: - deployment branches and tags: only the tag pattern `v*`; -- required reviewers: none. +- required reviewers: the DevOps team, with self-review disabled. Package installation and packing run in a separate build job with read-only repository and Actions permissions. The npm job receives only the packed tarball, has no checkout or install step, and alone gets `id-token: write`. diff --git a/test/release-workflows.sh b/test/release-workflows.sh index 41165b1..5bdc258 100755 --- a/test/release-workflows.sh +++ b/test/release-workflows.sh @@ -151,6 +151,18 @@ else failures=$((failures + 1)) fi +npm_actions_pinned=$(ruby -ryaml -e ' + steps = YAML.load_file(ARGV[0])["jobs"]["npm"]["steps"] + found = steps.select { |step| step["uses"] }.all? { |step| step["uses"].match?(/@[0-9a-f]{40}$/) } + print found +' "$workflows/publish.yml") +if [ "$npm_actions_pinned" = true ]; then + echo "ok PUBLISH npm job actions are SHA-pinned" +else + echo "FAIL PUBLISH npm job has a mutable action reference" + failures=$((failures + 1)) +fi + tag_steps=("Check the release branch" "Check CI on the branch head" "Tag the version in package.json") tagged='git/refs -f ref=refs/tags/v7\.0\.1 ' From a13e2fb9941b852a06bcf543b3b84b39c44f46d9 Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Thu, 15 Oct 2026 08:26:55 +0200 Subject: [PATCH 3/3] fix(DOPS-3023): publish downloaded tarball by path --- .github/workflows/publish.yml | 2 +- test/release-workflows.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 86f5938..04324f2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -102,7 +102,7 @@ jobs: env: DRY_RUN: ${{ inputs.dry-run }} run: | - package=$(find package -type f -name '*.tgz' -print -quit) + package=$(find ./package -type f -name '*.tgz' -print -quit) version=$(tar -xOf "$package" package/package.json | jq -r .version) args=("$package") if [[ "$version" == *-* ]]; then diff --git a/test/release-workflows.sh b/test/release-workflows.sh index 5bdc258..440e966 100755 --- a/test/release-workflows.sh +++ b/test/release-workflows.sh @@ -202,10 +202,10 @@ ci_run failure check "PUBLISH rejects tag without passed CI" "Check CI on the tag commit" - publish.yml 7.0.1 refs/tags/v7.0.1 "${publish_build_steps[@]}" ci_run success workflow_dispatch check "PUBLISH accepts dispatched CI" ok - publish.yml 7.0.1 refs/tags/v7.0.1 "${publish_build_steps[@]}" -check "PUBLISH from v7.0.1" ok '^npm publish package/package\.tgz $' publish.yml 7.0.1 refs/tags/v7.0.1 "Publish" -check "PUBLISH a pre-release from v7.1.0-rc.1" ok '^npm publish package/package\.tgz --tag next $' publish.yml 7.1.0-rc.1 refs/tags/v7.1.0-rc.1 "Publish" +check "PUBLISH from v7.0.1" ok '^npm publish \./package/package\.tgz $' publish.yml 7.0.1 refs/tags/v7.0.1 "Publish" +check "PUBLISH a pre-release from v7.1.0-rc.1" ok '^npm publish \./package/package\.tgz --tag next $' publish.yml 7.1.0-rc.1 refs/tags/v7.1.0-rc.1 "Publish" dry_run=true -check "PUBLISH dry-run" ok '^npm publish package/package\.tgz --dry-run $' publish.yml 7.0.1 refs/tags/v7.0.1 "Publish" +check "PUBLISH dry-run" ok '^npm publish \./package/package\.tgz --dry-run $' publish.yml 7.0.1 refs/tags/v7.0.1 "Publish" dry_run=false check "PUBLISH from main" "Check the release tag" - publish.yml 7.0.1 refs/heads/main "Check the release tag" "Publish"