diff --git a/.github/workflows/action-marketplace-readiness.yml b/.github/workflows/action-marketplace-readiness.yml new file mode 100644 index 00000000..69d82361 --- /dev/null +++ b/.github/workflows/action-marketplace-readiness.yml @@ -0,0 +1,213 @@ +name: Action Marketplace readiness + +# Marketplace-readiness pipeline for the composite action (action.yml) — +# separate from ci.yml's own dog-fooding (`own-check-codescan`, which uses +# `uses: ./` against Own.NET's own sample tree on every push/PR). This +# workflow proves the CONSUMER-facing surface against a small dedicated +# fixture that looks like an ordinary user's repo (not the precision-test +# corpus), plus the immutable-tag / moving-major-tag release handling. Also +# uses `uses: ./` — GitHub Actions does not evaluate expressions in +# `steps.uses`, so a genuinely dynamic remote `owner/repo@` +# reference isn't achievable pre-tag; see docs/notes/action-marketplace-readiness.md +# for the full account of what that leaves unproven and why. + +permissions: + contents: read + +on: + push: + branches: ["**"] + paths: + - "action.yml" + - "scripts/own-check.sh" + - "scripts/own-check.ps1" + - "fixtures/marketplace-consumer-demo/**" + - ".github/workflows/action-marketplace-readiness.yml" + tags: + - "v*.*.*" + pull_request: + paths: + - "action.yml" + - "scripts/own-check.sh" + - "scripts/own-check.ps1" + - "fixtures/marketplace-consumer-demo/**" + - ".github/workflows/action-marketplace-readiness.yml" + workflow_dispatch: + inputs: + move_major_tag_to: + description: >- + Release tag (e.g. v0.1.0) to point the moving major tag at. Leave + empty to just run the consumer-simulation checks. + required: false + default: "" + +jobs: + # The consumer-facing proof, against a small dedicated fixture instead of + # Own.NET's own precision-test corpus (ci.yml's `own-check-codescan` dog-food + # job already covers that against frontend/roslyn/samples). + # + # Uses `uses: ./`, NOT a dynamic `uses: PhysShell/Own.NET@${{ github.sha }}` + # (Codex review, PR #245): `jobs..steps.uses` does not evaluate + # expressions — GitHub's own context-availability docs don't list it as a + # field expressions may appear in (unlike `with`/`env`/`if`/`run`), so that + # ref would have been treated as a literal (and-broken) string and the job + # would never have run at all. There is no way to parameterize `uses:` with + # "the commit currently under test" — a genuinely dynamic remote-ref proof + # isn't automatable pre-tag. `uses: ./` is the correct, honest mechanism + # here (same one ci.yml's dog-food job already relies on); the meaningful + # difference from that job is the fixture, not the resolution mechanism. + # See docs/notes/action-marketplace-readiness.md for the full account, + # including what a genuinely separate consumer repo would additionally + # prove and why one wasn't created here. + consumer-simulation: + name: consumer simulation — dedicated fixture, real findings + runs-on: ubuntu-latest + # security-events:write is required by upload-sarif (Codex review, PR + # #245: `security-events: write` is REQUIRED for every workflow that + # calls it, not just push events) — every other step only needs the + # default contents:read. + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Own.NET check on the leak sample (fails as configured) + id: leak + uses: ./ + continue-on-error: true # expected to fail — asserted on explicitly below, not swallowed + with: + path: fixtures/marketplace-consumer-demo/Leaky.cs + format: github + fail-on-finding: "true" + - name: Assert the leak step actually failed (not silently green) + run: | + [ "${{ steps.leak.outcome }}" = "failure" ] \ + || { echo "FAIL: expected the leak-sample step to fail (fail-on-finding), got '${{ steps.leak.outcome }}'"; exit 1; } + echo "OK: consumer-style invocation found the leak and failed the step as configured" + + - name: Own.NET check on the clean sample (must NOT fail) + uses: ./ + with: + path: fixtures/marketplace-consumer-demo/Clean.cs + format: github + fail-on-finding: "true" + + # Fork PRs get a read-only GITHUB_TOKEN (GitHub's fork-PR token policy), + # so security-events:write is never actually granted no matter what + # this job requests — skip the SARIF/upload steps there instead of + # failing red for external contributors through no fault of their own + # (Codex review, PR #245; mirrors ci.yml's own-check-codescan guard). + # Same-repo pushes/PRs and tag pushes still run it. + - name: Own.NET check, SARIF surface + upload-sarif wiring + id: sarif + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: ./ + with: + path: fixtures/marketplace-consumer-demo + format: sarif + severity: warning + fail-on-finding: "false" + - name: The action exposes a non-empty sarif-file output + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + run: | + f="${{ steps.sarif.outputs.sarif-file }}" + test -n "$f" || { echo "FAIL: sarif-file output not set"; exit 1; } + test -s "$f" || { echo "FAIL: sarif-file '$f' missing or empty"; exit 1; } + echo "OK: $(wc -c < "$f") bytes of SARIF" + - name: Upload to GitHub code scanning (the real consumer path) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + with: + sarif_file: ${{ steps.sarif.outputs.sarif-file }} + category: action-marketplace-consumer-demo + + # Immutable version tag: a pushed vX.Y.Z is never mutated once pushed — + # this job only VALIDATES it (re-runs the consumer-simulation checks + # implicitly via `needs`, plus a metadata sanity pass). It never moves any + # tag itself. + validate-release-tag: + name: validate the pushed release tag + # event_name == 'push' required alongside the ref check (same class of + # gap Codex flagged on ownsharp-cli-release.yml, PR #244): github.ref + # alone doesn't prove the run was actually caused by a tag push, since a + # workflow_dispatch run can be pointed `--ref` at an existing tag too. + # This job only validates (never mutates/publishes), so the stakes are + # lower than the CLI's publish gate, but the fix is the same and free. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: consumer-simulation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: action.yml has the fields Marketplace publishing requires + run: | + python3 - <<'PY' + import sys, re + text = open("action.yml", encoding="utf-8").read() + # Cheap structural checks (no YAML+schema dependency in this repo's + # zero-dependency Python core) - enough to catch an accidental + # metadata regression before it reaches a real tag/publish. + for field in ("name:", "description:", "author:", "branding:", "icon:", "color:"): + assert field in text, f"action.yml missing '{field}'" + m = re.search(r'icon:\s*"([^"]+)"', text) + c = re.search(r'color:\s*"([^"]+)"', text) + assert m, "branding.icon not found" + assert c, "branding.color not found" + allowed_colors = {"white", "yellow", "blue", "green", "orange", "red", "purple", "gray-dark"} + assert c.group(1) in allowed_colors, f"branding.color '{c.group(1)}' not in Marketplace's allowed set {allowed_colors}" + print(f"OK: action.yml metadata present; icon={m.group(1)!r} color={c.group(1)!r}") + PY + - name: Tag is immutable from here — this job only validates, never mutates + run: echo "Tag ${{ github.ref_name }} at ${{ github.sha }} validated. Moving the major tag is a SEPARATE, explicit workflow_dispatch step — see move-major-tag." + + # The ONLY place a major tag (e.g. v0) is ever moved. Deliberately + # `workflow_dispatch`-only with an explicit target — never runs from a + # plain tag push, so pushing v0.1.1 can never silently repoint v0 as a + # side effect. Force-moving a tag is a hard-to-reverse, externally-visible + # action (equivalent to a force-push), so it is gated behind the + # `action-major-tag-move` GitHub Environment, which a repo admin must + # configure with required reviewers before this can run unattended. + move-major-tag: + name: move the major tag (protected, manual only) + if: github.event_name == 'workflow_dispatch' && inputs.move_major_tag_to != '' + runs-on: ubuntu-latest + environment: action-major-tag-move + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: true + fetch-depth: 0 + # Inputs flow through the environment (data), not template-interpolated + # into the script body (code) — same pattern action.yml itself already + # follows (its own CodeRabbit #10 fix) and CodeRabbit flagged here too + # (PR #245): a `move_major_tag_to` containing shell metacharacters would + # otherwise expand before bash parses the script. The SemVer format + # check below is a second, independent guard, not a substitute for it. + - name: Resolve and validate the target release tag + id: target + env: + MOVE_MAJOR_TAG_TO: ${{ inputs.move_major_tag_to }} + run: | + target="$MOVE_MAJOR_TAG_TO" + echo "$target" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ + || { echo "FAIL: '$target' is not a valid vX.Y.Z tag"; exit 1; } + git rev-parse "refs/tags/$target" >/dev/null 2>&1 \ + || { echo "FAIL: tag '$target' does not exist — push it first, this job never creates release tags"; exit 1; } + major="${target%%.*}" # "v0.1.0" -> "v0" + echo "target=$target" >> "$GITHUB_OUTPUT" + echo "major=$major" >> "$GITHUB_OUTPUT" + - name: Force-move the major tag to the target release commit + env: + TARGET: ${{ steps.target.outputs.target }} + MAJOR: ${{ steps.target.outputs.major }} + run: | + sha=$(git rev-parse "refs/tags/$TARGET") + echo "Moving $MAJOR -> $TARGET ($sha)" + git tag -f "$MAJOR" "$sha" + git push origin "$MAJOR" --force diff --git a/README.md b/README.md index 459f6eac..8c61ed3b 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,17 @@ release. ```yaml - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 -- uses: PhysShell/own.net@main # pre-release: no tagged release yet — pin a commit SHA for reproducibility +- uses: PhysShell/Own.NET@main # pre-release: no tagged release yet — pin a commit SHA for reproducibility with: format: github # inline PR annotations; use "sarif" for the Security tab fail-on-finding: "true" ``` +Once a release ships, prefer a pinned tag (`@v0.1.0`) or the moving major tag +(`@v0`) over `@main` — see +[`docs/notes/action-marketplace-readiness.md`](docs/notes/action-marketplace-readiness.md) +for the versioning policy. + ## Or point it at a repo you already have ```bash diff --git a/README.ru.md b/README.ru.md index ec3badef..589e5754 100644 --- a/README.ru.md +++ b/README.ru.md @@ -14,12 +14,16 @@ ```yaml - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 -- uses: PhysShell/own.net@main # пре-релиз: тегов ещё нет — для воспроизводимости пиньте commit SHA +- uses: PhysShell/Own.NET@main # пре-релиз: тегов ещё нет — для воспроизводимости пиньте commit SHA with: format: github # инлайн-аннотации в PR; "sarif" — для вкладки Security fail-on-finding: "true" ``` +После первого релиза предпочитайте закреплённый тег (`@v0.1.0`) или +подвижный major-тег (`@v0`) вместо `@main` — политика версионирования в +[`docs/notes/action-marketplace-readiness.md`](docs/notes/action-marketplace-readiness.md). + ## Или локально, на репозитории, который уже есть ```bash diff --git a/action.yml b/action.yml index 23d1113e..1437f3bf 100644 --- a/action.yml +++ b/action.yml @@ -55,12 +55,12 @@ runs: using: "composite" steps: - name: Set up Python (Own.NET core) - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ inputs.python-version }} - name: Set up .NET (Roslyn extractor) - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: ${{ inputs.dotnet-version }} diff --git a/docs/notes/action-marketplace-readiness.md b/docs/notes/action-marketplace-readiness.md new file mode 100644 index 00000000..737b4828 --- /dev/null +++ b/docs/notes/action-marketplace-readiness.md @@ -0,0 +1,141 @@ +# GitHub Action Marketplace readiness (P-013 gate B) + +Companion to [`docs/notes/ownsharp-cli-release.md`](ownsharp-cli-release.md) +(the NuGet-package side of release readiness) — this note covers `action.yml`, +the composite action at the repo root. As with the CLI note, **no Marketplace +listing was published**; this documents what was validated and what a +maintainer still has to do to actually list it. + +## Metadata / branding audit + +`action.yml`'s Marketplace-required fields were checked against GitHub's +actual constraints, not just "does it have the keys": + +- `name`, `description`, `author` — present, accurate to what the action does. +- `branding.icon: "shield"` — a real [Feather icon](https://feathericons.com/), + in Marketplace's allowed icon set. +- `branding.color: "purple"` — in Marketplace's fixed 8-color list (`white`, + `yellow`, `blue`, `green`, `orange`, `red`, `purple`, `gray-dark`). +- `description` is 230 characters flattened — Marketplace's card view + truncates around ~125; left as-is since shortening it would cost real + information and this is a cosmetic nicety, not a publish blocker. + +**Fixed:** both nested `uses:` steps inside the composite action +(`actions/setup-python@v5`, `actions/setup-dotnet@v4`) were floating major- +version tags, inconsistent with this repo's own stated convention (`ci.yml`: +"every third-party `uses:` is pinned to a commit SHA"). Pinned to the exact +SHAs `ci.yml` already uses and verified independently via `git ls-remote +--tags` against both upstream repos (not copied on faith). + +## Versioning policy — immutable release tag + explicitly-gated moving major tag + +Distinct tag namespace from the CLI's `ownsharp-cli-v*` (same repo, two +release surfaces — a shared bare `v*` would be ambiguous about which +artifact a tag names): + +- **Action release tags are bare SemVer: `vMAJOR.MINOR.PATCH`** (e.g. + `v0.1.0`) — the form GitHub's own "Releasing and maintaining actions" guide + documents, and what a consumer's `uses: owner/repo@v1` expects to resolve. +- **Immutable once pushed.** `.github/workflows/action-marketplace-readiness.yml`'s + `validate-release-tag` job runs on a `v*.*.*` tag push and only *validates* + (metadata sanity + the consumer-simulation checks via `needs:`) — it never + writes to the repository. +- **The moving major tag (`v0` while pre-1.0, `v1` after) only ever moves + through a separate, explicit `workflow_dispatch`** (`move-major-tag` job, + input `move_major_tag_to: vX.Y.Z`) — never as a side effect of pushing a + patch release. Force-moving a tag is equivalent to a force-push (rewrites + what a consumer pinned to the major tag gets next), so the job additionally + requires the `action-major-tag-move` GitHub Environment — **a repo admin + must configure that with required reviewers before this can run + unattended; it does not exist yet.** +- Both `github.event_name == 'push'` *and* the ref-prefix check gate the + tag-triggered jobs — `github.ref` alone doesn't prove a tag was actually + pushed, since a `workflow_dispatch` run can be pointed `--ref` at an + existing tag too (the same class of gap independent review caught on the + CLI's publish gate in PR #244 — fixed here from the start). + +## Consumer-simulation fixture + +`fixtures/marketplace-consumer-demo/` — deliberately separate from +`frontend/roslyn/samples/` (Own.NET's own extractor precision-test corpus, +not representative of an ordinary consumer's code): one file with an +unambiguous leak (`Leaky.cs`, `OWN001`), one clean negative control +(`Clean.cs`). `action-marketplace-readiness.yml`'s `consumer-simulation` job +runs the action against it via `uses: ./`. + +**Correction (Codex review, PR #245):** an earlier version of this job tried +`uses: PhysShell/Own.NET@${{ github.sha }}`, intending a genuinely remote, +resolved-by-the-runner reference instead of a local path. That does not +work: GitHub Actions does not evaluate expressions in +`jobs..steps.uses` at all (it is not among the fields the +context-availability docs list as expression-capable, unlike `with`/`env`/ +`if`/`run`) — the string would have been passed through literally and the +job would never have resolved an action, let alone run one. There is no +mechanism to parameterize `uses:` with "the commit currently under test"; +a genuinely dynamic remote-ref proof is not automatable in a pre-tag +workflow. `uses: ./` is the correct, honest mechanism — the same one +`ci.yml`'s `own-check-codescan` job already relies on. The meaningful +difference from that job is the fixture (a small consumer-style pair, not +the precision-test corpus) and the release/tag-validation wiring around it, +not the resolution mechanism, which was never a real option here. + +Verifies: the leak sample fails the step (`fail-on-finding: true`, asserted +via `steps.leak.outcome`), the clean sample does not, and the SARIF surface +produces a non-empty `sarif-file` output that `github/codeql-action/upload-sarif` +accepts (skipped on fork PRs, which get a read-only `GITHUB_TOKEN` that can +never satisfy `security-events: write` — same guard `own-check-codescan` +already uses, applied here after Codex flagged the same gap on this job). + +**Honest limitation:** even `uses: ./` is not a full substitute for a +genuinely separate consumer repository (which would additionally prove +resolution against an entirely different git remote/identity, and would be +the only way to actually exercise a real `owner/repo@vX.Y.Z` reference). +A second public repository was not created for this — creating a new public +repo is a visible, user-facing action, not this session's call to make +without being asked. If a maintainer wants that stronger proof, cloning +`fixtures/marketplace-consumer-demo/` into a throwaway public repo and +pointing the 6-line README snippet at a real pushed tag is a five-minute +follow-up — and the only way to genuinely exercise dynamic remote `uses:` +resolution at all, pre-tag CI or not. + +`ci.yml`'s pre-existing `own-check-codescan` job (dog-fooding via `uses: ./` +against `frontend/roslyn/samples` on every push/PR) is untouched — this +workflow is the release-readiness path, not a replacement for that fast +per-push proof. + +## README accuracy + +Fixed a repo-name casing drift in both `README.md` and `README.ru.md`: +`PhysShell/own.net@main` → `PhysShell/Own.NET@main` (GitHub resolves both +case-insensitively, but a Marketplace listing and consumer-facing docs +should use the actual casing). Added a pointer to this note's versioning +policy so the "6-line" snippet tells a reader what to switch to once a +release exists, instead of only describing the pre-release state. + +## Marketplace publish checklist (not run — for whoever does) + +1. Confirm a `LICENSE` exists (same blocker `ownsharp-cli-release.md` + tracks for the NuGet package — Marketplace listing requires a license on + the repo too). +2. Push a `vX.Y.Z` tag; confirm `validate-release-tag` and + `consumer-simulation` both pass on it. +3. `workflow_dispatch` → `move-major-tag` with that tag, approve the + `action-major-tag-move` environment gate (once configured). +4. Update the README's "Run it in CI" snippet to the real tag (`@v0` or + `@v0.1.0`), replacing the `@main` pre-release note. +5. List on Marketplace via the repository's own Release UI ("Publish this + Action to the GitHub Marketplace" checkbox on a Release) — a manual, + human step; nothing in this repo's workflows does this automatically, by + design (Marketplace listing is a one-way, account-linked action). + +## Boundaries honored + +- Not published to Marketplace. +- No analyzer logic touched — `scripts/own-check.sh`/`.ps1` and the core + detectors are unchanged; only `action.yml`'s nested step pins, the new + release/consumer-simulation workflow, the fixture, and doc/README + accuracy changed. +- No failure hidden behind `continue-on-error`: the one place it's used + (`consumer-simulation`'s leak-sample step) is immediately followed by an + explicit assertion on `steps.leak.outcome` — the point is to observe and + check a *specific expected* failure, not to swallow an unexpected one. diff --git a/fixtures/marketplace-consumer-demo/Clean.cs b/fixtures/marketplace-consumer-demo/Clean.cs new file mode 100644 index 00000000..62f4daf7 --- /dev/null +++ b/fixtures/marketplace-consumer-demo/Clean.cs @@ -0,0 +1,12 @@ +using System.IO; + +namespace ConsumerDemo; + +public class Tidy +{ + public void Run() + { + using var buffer = new MemoryStream(); + buffer.WriteByte(1); + } +} diff --git a/fixtures/marketplace-consumer-demo/Leaky.cs b/fixtures/marketplace-consumer-demo/Leaky.cs new file mode 100644 index 00000000..bbc0e695 --- /dev/null +++ b/fixtures/marketplace-consumer-demo/Leaky.cs @@ -0,0 +1,13 @@ +using System.IO; + +namespace ConsumerDemo; + +public class Leaky +{ + public void Run() + { + var buffer = new MemoryStream(); + buffer.WriteByte(1); + // no Dispose()/using -> OWN001 + } +} diff --git a/fixtures/marketplace-consumer-demo/README.md b/fixtures/marketplace-consumer-demo/README.md new file mode 100644 index 00000000..71b52aef --- /dev/null +++ b/fixtures/marketplace-consumer-demo/README.md @@ -0,0 +1,31 @@ +# Marketplace consumer-simulation fixture + +This directory stands in for "a repo that installs the Own.NET GitHub +Action" — deliberately small, deliberately *not* part of +`frontend/roslyn/samples` (Own.NET's own extractor precision-test corpus, +which mixes many deliberate positive/negative cases and is not +representative of an ordinary consumer's code). + +Used by `.github/workflows/action-marketplace-readiness.yml`'s +`consumer-simulation` job, via `uses: ./` (GitHub Actions does not evaluate +expressions in `steps.uses`, so a dynamic `uses: PhysShell/Own.NET@` isn't achievable — see `docs/notes/action-marketplace-readiness.md` +for the full account). The meaningful difference from `ci.yml`'s own +`uses: ./` dog-food job is this fixture: small and consumer-shaped, not +Own.NET's own precision-test corpus. + +- `Leaky.cs` — one intentional, unambiguous leak (`OWN001`): a + `MemoryStream` local that is never disposed. Exists to prove the action + actually finds something and annotates it, not just that it runs and + exits cleanly. +- `Clean.cs` — the same shape, disposed correctly. Exists to prove the + action does *not* fail a normal, correct file (no false alarm on the + negative control). + +This is an honest approximation, not a full substitute for a genuinely +separate consumer repository — a real external repo would additionally +prove the action resolves for a checkout with an entirely different git +remote/identity. That was not created here without explicit authorization +(creating a second public repository is a visible, user-facing action); +see `docs/notes/action-marketplace-readiness.md` for the tradeoff this +records.