diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 076f038..abbf7f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + package-manager-cache: false + - name: Pin npm client + run: npm install --global npm@11.17.0 - name: Build release artifacts reproducibly run: | go run ./scripts/release --version 2.0.0-dev --commit "$GITHUB_SHA" --output dist diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9f92f0e..d77a0c2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,6 +31,9 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Pin trusted-publishing npm client + run: npm install --global npm@11.17.0 - name: Validate release identity id: version shell: bash @@ -88,8 +91,7 @@ jobs: for tarball in npm-packs/*.tgz; do name="$(tar -xOzf "$tarball" package/package.json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).name')" version="${{ steps.version.outputs.version }}" - remote="$(npm view "$name@$version" dist.integrity --json 2>/dev/null || true)" - if [[ -n "$remote" ]]; then + if remote="$(npm view "$name@$version" dist.integrity --json 2>/dev/null)"; then remote="$(node -p 'JSON.parse(process.argv[1])' "$remote")" local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | base64 -w 0)" test "$remote" = "$local_integrity" @@ -115,3 +117,30 @@ jobs: else npm publish "$tarball" --access public --provenance fi + - name: Verify exact registry artifacts + shell: bash + run: | + for tarball in npm-packs/*.tgz; do + name="$(tar -xOzf "$tarball" package/package.json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).name')" + version="${{ steps.version.outputs.version }}" + local_shasum="$(sha1sum "$tarball" | awk '{print $1}')" + local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | base64 -w 0)" + remote="" + for attempt in {1..12}; do + if remote="$(npm view --prefer-online "$name@$version" dist --json 2>/dev/null)"; then break; fi + remote="" + if [[ "$attempt" -lt 12 ]]; then sleep 5; fi + done + test -n "$remote" + test "$(node -p 'JSON.parse(process.argv[1]).shasum' "$remote")" = "$local_shasum" + test "$(node -p 'JSON.parse(process.argv[1]).integrity' "$remote")" = "$local_integrity" + done + - name: Smoke exact package from npm registry + run: | + mkdir "$RUNNER_TEMP/npm-registry-smoke" + cd "$RUNNER_TEMP/npm-registry-smoke" + npm init -y >/dev/null + npm install --ignore-scripts --no-audit --no-fund --prefer-online \ + "mattermost-cli@${{ steps.version.outputs.version }}" >/dev/null + test "$(./node_modules/.bin/mm --version)" = "mm version ${{ steps.version.outputs.version }} (${{ steps.version.outputs.commit }})" + ./node_modules/.bin/mm --help diff --git a/internal/stageinput/input.go b/internal/stageinput/input.go index 00068e7..6f5283e 100644 --- a/internal/stageinput/input.go +++ b/internal/stageinput/input.go @@ -82,6 +82,10 @@ func Preflight(inputs []Attachment) ([]MetadataIntent, error) { // upload; a changed path must conflict with this recorded identity and digest. // Bind returns no partial result and never returns contaminated values. func Bind(ctx context.Context, inputs []Attachment, credentials [][]byte) ([]stagestore.Attachment, error) { + return bind(ctx, inputs, credentials, nil) +} + +func bind(ctx context.Context, inputs []Attachment, credentials [][]byte, afterOpen func(string) error) ([]stagestore.Attachment, error) { if ctx == nil { return nil, ErrInvalid } @@ -116,6 +120,12 @@ func Bind(ctx context.Context, inputs []Attachment, credentials [][]byte) ([]sta if err != nil { return nil, err } + if afterOpen != nil { + if hookErr := afterOpen(input.canonical); hookErr != nil { + _ = file.Close() + return nil, hookErr + } + } digest, length, prefix, scanErr := scanFile(ctx, file, scanner.stream()) after, statErr := fileIdentityOf(file) closeErr := file.Close() diff --git a/internal/stageinput/input_test.go b/internal/stageinput/input_test.go index 7b2cc5e..032f397 100644 --- a/internal/stageinput/input_test.go +++ b/internal/stageinput/input_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "errors" + "fmt" "io" "os" "path/filepath" @@ -454,15 +455,12 @@ func TestBindRejectsReplacementDuringScan(t *testing.T) { if err := os.WriteFile(replacement, content, 0o600); err != nil { t.Fatal(err) } - done := make(chan error, 1) - go func() { - time.Sleep(time.Millisecond) - done <- os.Rename(replacement, path) - }() - got, err := Bind(context.Background(), []Attachment{{Path: path, RemoteFilename: "large"}}, nil) - if renameErr := <-done; renameErr != nil { - t.Fatal(renameErr) - } + got, err := bind(context.Background(), []Attachment{{Path: path, RemoteFilename: "large"}}, nil, func(openedPath string) error { + if openedPath != path { + return fmt.Errorf("opened path %q, want %q", openedPath, path) + } + return os.Rename(replacement, path) + }) if (!errors.Is(err, ErrFileChanged) && !errors.Is(err, ErrUnsafeFile)) || got != nil { t.Fatalf("result=%v err=%v", got, err) }