diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f7685de..6184258 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,7 +5,8 @@ ## Checklist - [ ] No secrets committed -- [ ] Built wrapper: `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/build.sh` -- [ ] Collected SDK xcframeworks: `bash src/Kapusch.FacebookApisForiOSComponents/Native/iOS/collect-facebook-xcframeworks.sh` -- [ ] Packed NuGet: `dotnet pack src/Kapusch.FacebookApisForiOSComponents/Kapusch.FacebookApisForiOSComponents.csproj -c Release -o artifacts/nuget` +- [ ] Built wrapper: `bash src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/build.sh` +- [ ] Built package project and sample +- [ ] Packed and validated the NuGet with `scripts/validate-nupkg.py` +- [ ] Parsed modified workflows - [ ] Updated docs if behavior/integration changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae72c8..70e0f2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: ci on: pull_request: + push: + branches: + - master workflow_dispatch: permissions: @@ -9,16 +12,19 @@ permissions: jobs: build_pack_ios: - runs-on: macos-latest + runs-on: macos-15 steps: - uses: actions/checkout@v4 + - name: Select Xcode 26.3 + run: sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer + - uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + global-json-file: global.json - name: Install iOS workload - run: dotnet workload install ios + run: dotnet workload install ios --version 10.0.202 - name: Build iOS wrapper run: | @@ -28,40 +34,16 @@ jobs: run: | dotnet build src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release + - name: Build sample + run: | + dotnet build samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj -c Release + - name: Pack run: | dotnet pack src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release -o artifacts/nuget - name: Validate nupkg layout - run: | - python3 - <<'PY' - import glob, sys, zipfile - - nupkgs = glob.glob("artifacts/nuget/*.nupkg") - if not nupkgs: - print("ERROR: no .nupkg found under artifacts/nuget/") - sys.exit(1) - - nupkg = sorted(nupkgs)[-1] - print(f"Validating {nupkg}") - - z = zipfile.ZipFile(nupkg) - names = set(z.namelist()) - - required = [ - "buildTransitive/Kapusch.StoreKit2.iOS.targets", - "kstorekit2.xcframework/Info.plist", - ] - - missing = [p for p in required if p not in names] - if missing: - print("ERROR: missing required paths in nupkg:") - for p in missing: - print(f" - {p}") - sys.exit(1) - - print("OK: nupkg layout looks correct.") - PY + run: python3 scripts/validate-nupkg.py artifacts/nuget/*.nupkg - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 058a11b..18fca0b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,11 @@ on: workflow_dispatch: inputs: manual_version: - description: 'Force a specific version for NuGet.org publish (e.g., 1.0.0 or 1.0.0-rc.1). If empty, defaults to auto-generated preview for GitHub Packages.' + description: 'Publish this immutable version to NuGet.org (for example 1.1.0 or 1.1.0-rc.1). Must be dispatched from master. Empty publishes a preview to GitHub Packages.' + required: false + type: string + source_ref: + description: 'Optional historical tag, branch, or commit to package. It must resolve to a commit reachable from origin/master.' required: false type: string push: @@ -17,7 +21,7 @@ permissions: jobs: pack_and_publish: - runs-on: macos-latest + runs-on: macos-15 permissions: contents: read packages: write @@ -27,12 +31,64 @@ jobs: with: fetch-depth: 0 + - name: Select Xcode 26.3 + run: sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer + + - name: Resolve and authorize package source + id: source + shell: bash + env: + MANUAL_VERSION: ${{ github.event.inputs.manual_version }} + SOURCE_REF: ${{ github.event.inputs.source_ref }} + run: | + git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' + git fetch --tags origin + + if [[ "${{ github.event_name }}" == "workflow_dispatch" && -n "$SOURCE_REF" ]]; then + if RESOLVED_SHA=$(git rev-parse --verify --end-of-options "${SOURCE_REF}^{commit}" 2>/dev/null); then + : + else + BRANCH_NAME="${SOURCE_REF#refs/heads/}" + RESOLVED_SHA=$(git rev-parse --verify --end-of-options "origin/${BRANCH_NAME}^{commit}") + fi + git checkout --detach "$RESOLVED_SHA" + else + RESOLVED_SHA=$(git rev-parse --verify HEAD^{commit}) + fi + + if [[ -n "$MANUAL_VERSION" && "${{ github.ref }}" != "refs/heads/master" ]]; then + echo "ERROR: a manual NuGet.org version publish must be dispatched from master." + exit 1 + fi + + REQUIRE_MASTER_REACHABILITY="false" + if [[ "${{ github.ref }}" == refs/tags/v* || -n "$MANUAL_VERSION" || -n "$SOURCE_REF" ]]; then + REQUIRE_MASTER_REACHABILITY="true" + fi + + if [[ "$REQUIRE_MASTER_REACHABILITY" == "true" ]] && ! git merge-base --is-ancestor "$RESOLVED_SHA" origin/master; then + echo "ERROR: package source $RESOLVED_SHA is not reachable from origin/master." + exit 1 + fi + + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + TAG_SHA=$(git rev-list -n 1 "${{ github.ref }}") + if [[ "$TAG_SHA" != "$RESOLVED_SHA" ]]; then + echo "ERROR: tag target $TAG_SHA does not match checked-out source $RESOLVED_SHA." + exit 1 + fi + fi + + echo "sha=${RESOLVED_SHA}" >> "$GITHUB_OUTPUT" + echo "Package source input: ${SOURCE_REF:-}" + echo "Package source SHA: $RESOLVED_SHA" + echo "origin/master SHA: $(git rev-parse origin/master)" + - name: Determine package version id: version shell: bash run: | REF="${{ github.ref }}" - EVENT="${{ github.event_name }}" MANUAL_VERSION="${{ github.event.inputs.manual_version }}" CSPROJ="src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj" BASE_VERSION=$(grep -oE '[^<]+' "$CSPROJ" | head -n 1 | sed 's///' || echo "1.0.0") @@ -40,9 +96,14 @@ jobs: if [[ -n "$MANUAL_VERSION" ]]; then VERSION="$MANUAL_VERSION" PUBLISH_TARGET="nuget" - echo "Manual version provided ($VERSION). Triggering NuGet.org publish." + echo "Manual version provided ($VERSION)." elif [[ "$REF" =~ ^refs/tags/v ]]; then - VERSION="${REF#refs/tags/v}" + TAG="${REF#refs/tags/}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "ERROR: release tags must be vX.Y.Z or vX.Y.Z-rc.N." + exit 1 + fi + VERSION="${TAG#v}" PUBLISH_TARGET="nuget" else COMMIT_SHORT=$(git rev-parse --short HEAD) @@ -51,29 +112,9 @@ jobs: PUBLISH_TARGET="github" fi - if [[ "$PUBLISH_TARGET" == "nuget" ]]; then - git fetch origin +refs/heads/*:refs/remotes/origin/* - TAGGED_SHA="${{ github.sha }}" - CONTAINING_BRANCHES=$(git for-each-ref --format='%(refname:short)' refs/remotes/origin --contains "$TAGGED_SHA") - - ON_MASTER="false" - ON_RELEASE="false" - while IFS= read -r branch; do - [[ "$branch" == "origin/master" ]] && ON_MASTER="true" - [[ "$branch" == origin/release/* ]] && ON_RELEASE="true" - done <<< "$CONTAINING_BRANCHES" - - if [[ "$VERSION" == *-* ]]; then - if [[ "$ON_RELEASE" != "true" ]]; then - echo "ERROR: Pre-release version $VERSION must be on origin/release/*" - exit 1 - fi - else - if [[ "$ON_MASTER" != "true" ]]; then - echo "ERROR: Stable version $VERSION must be on origin/master" - exit 1 - fi - fi + if [[ "$PUBLISH_TARGET" == "nuget" && ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "ERROR: NuGet.org versions must be X.Y.Z or X.Y.Z-rc.N." + exit 1 fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" @@ -81,13 +122,58 @@ jobs: echo "Package version: ${VERSION}" echo "Publish target: ${PUBLISH_TARGET}" + - name: Protect existing tag provenance + if: steps.version.outputs.publish_target == 'nuget' + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + run: | + TAG_REF="refs/tags/v${VERSION}" + if git show-ref --verify --quiet "$TAG_REF"; then + TAG_SHA=$(git rev-list -n 1 "$TAG_REF") + if [[ "$TAG_SHA" != "$SOURCE_SHA" ]]; then + echo "ERROR: existing tag v${VERSION} targets $TAG_SHA, not package source $SOURCE_SHA." + echo "Existing tags are immutable and will not be moved." + exit 1 + fi + echo "Existing tag v${VERSION} already records package source $SOURCE_SHA; it remains unchanged." + else + echo "No existing v${VERSION} tag; no tag will be created or moved by this workflow." + fi + + - name: Verify NuGet.org version is unused + if: steps.version.outputs.publish_target == 'nuget' + env: + PACKAGE_VERSION: ${{ steps.version.outputs.version }} + run: | + python3 - <<'PY' + import json + import os + import sys + import urllib.request + + version = os.environ["PACKAGE_VERSION"].lower() + url = "https://api.nuget.org/v3-flatcontainer/kapusch.storekit2.ios/index.json" + print(f"Checking official NuGet index: {url}") + with urllib.request.urlopen(url) as response: + versions = {item.lower() for item in json.load(response).get("versions", [])} + + if version in versions: + print(f"ERROR: Kapusch.StoreKit2.iOS {version} already exists on NuGet.org.") + print("NuGet versions are immutable; refusing to attempt a republish.") + sys.exit(1) + + print(f"OK: Kapusch.StoreKit2.iOS {version} is not present on NuGet.org.") + PY + - uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + global-json-file: global.json - name: Install iOS workload run: | - dotnet workload install ios + dotnet workload install ios --version 10.0.202 - name: Cache NuGet uses: actions/cache@v4 @@ -104,43 +190,57 @@ jobs: dotnet pack src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj \ -c Release \ -o artifacts/nuget \ - /p:PackageVersion="${{ steps.version.outputs.version }}" + /p:PackageVersion="${{ steps.version.outputs.version }}" \ + /p:RepositoryCommit="${{ steps.source.outputs.sha }}" \ + /p:ContinuousIntegrationBuild=true - name: Validate nupkg layout + run: python3 scripts/validate-nupkg.py artifacts/nuget/*.nupkg + + - name: Record package provenance + env: + PACKAGE_VERSION: ${{ steps.version.outputs.version }} + PUBLISH_TARGET: ${{ steps.version.outputs.publish_target }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + SOURCE_REF_INPUT: ${{ github.event.inputs.source_ref }} + TRIGGER_REF: ${{ github.ref }} + WORKFLOW_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | python3 - <<'PY' - import glob, sys, zipfile - - nupkgs = glob.glob("artifacts/nuget/*.nupkg") - if not nupkgs: - print("ERROR: no .nupkg found under artifacts/nuget/") - sys.exit(1) - - nupkg = sorted(nupkgs)[-1] - print(f"Validating {nupkg}") + import glob + import hashlib + import json + import os + from pathlib import Path - z = zipfile.ZipFile(nupkg) - names = set(z.namelist()) + packages = glob.glob("artifacts/nuget/*.nupkg") + if len(packages) != 1: + raise SystemExit(f"ERROR: expected one nupkg, found {len(packages)}") - required = [ - "buildTransitive/Kapusch.StoreKit2.iOS.targets", - "kstorekit2.xcframework/Info.plist", - ] - - missing = [p for p in required if p not in names] - if missing: - print("ERROR: missing required paths in nupkg:") - for p in missing: - print(f" - {p}") - sys.exit(1) - - if "Info.plist" in names: - print("ERROR: wrapper appears flattened (found 'Info.plist' at package root).") - sys.exit(1) - - print("OK: nupkg layout looks correct.") + package_path = Path(packages[0]) + provenance = { + "package": "Kapusch.StoreKit2.iOS", + "version": os.environ["PACKAGE_VERSION"], + "source_sha": os.environ["SOURCE_SHA"], + "trigger_ref": os.environ["TRIGGER_REF"], + "source_ref_input": os.environ.get("SOURCE_REF_INPUT", ""), + "publish_target": os.environ["PUBLISH_TARGET"], + "run_url": os.environ["WORKFLOW_RUN_URL"], + "nupkg": package_path.name, + "nupkg_sha256": hashlib.sha256(package_path.read_bytes()).hexdigest(), + } + output = Path("artifacts/package-provenance.json") + output.write_text(json.dumps(provenance, indent=2, sort_keys=True) + "\n") + print(output.read_text()) PY + - uses: actions/upload-artifact@v4 + with: + name: package-${{ steps.version.outputs.version }}-${{ steps.source.outputs.sha }} + path: | + artifacts/nuget/*.nupkg + artifacts/package-provenance.json + - name: Push to NuGet.org if: steps.version.outputs.publish_target == 'nuget' uses: NuGet/login@v1 @@ -153,8 +253,7 @@ jobs: run: | dotnet nuget push artifacts/nuget/*.nupkg \ --api-key "${{ steps.nuget_login.outputs.NUGET_API_KEY }}" \ - --source "https://api.nuget.org/v3/index.json" \ - --skip-duplicate + --source "https://api.nuget.org/v3/index.json" - name: Publish to GitHub Packages if: steps.version.outputs.publish_target == 'github' diff --git a/.github/workflows/sample-ios.yml b/.github/workflows/sample-ios.yml index 4821ebc..31e7b6e 100644 --- a/.github/workflows/sample-ios.yml +++ b/.github/workflows/sample-ios.yml @@ -7,7 +7,23 @@ permissions: contents: read jobs: - placeholder: - runs-on: macos-latest + build: + runs-on: macos-15 steps: - - run: echo "No iOS sample project yet." + - uses: actions/checkout@v4 + + - name: Select Xcode 26.3 + run: sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install iOS workload + run: dotnet workload install ios --version 10.0.202 + + - name: Build iOS wrapper + run: bash src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/build.sh + + - name: Build sample + run: dotnet build samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj -c Release diff --git a/AGENTS.md b/AGENTS.md index 54148a4..9ef53e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,17 @@ - Keep the managed API minimal and stable. - Never commit secrets or credentials. +## Branch and release contract +- `master` is the only long-lived branch. Use short-lived feature branches and pull requests. +- Never implement work directly on `release/*`, rewrite published history, force-push shared + branches, or move an existing tag. +- Both `vX.Y.Z-rc.N` and `vX.Y.Z` must target commits reachable from `origin/master`. +- A versionless manual workflow run is a GitHub Packages preview. A manual NuGet.org version publish + must run from `master` and must use an unused version. +- Historical packaging requires an explicit `source_ref` reachable from `origin/master`. +- Follow `Docs/Release.md`; the workflow and validation scripts are deterministic and take + precedence if prose disagrees. + ## Packaging constraints - Public OSS repo with generic docs and examples. - NuGet ships the native interop wrapper `xcframework` and injects it with `NativeReference`. @@ -18,3 +29,4 @@ ## Safety - Never log purchase tokens or signed payloads in clear text. - Keep changes deterministic and reproducible in CI. +- Preserve all historical tags and package provenance when retiring branches. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 725d8ca..d5f2dd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,31 +4,51 @@ Thanks for contributing. ## Prerequisites -- macOS with Xcode installed (required for iOS SDK tooling) -- .NET SDK 10 (this repo pins `10.0.100` via `global.json`) +- macOS with Xcode 26.3 installed (required by the pinned .NET iOS workload) +- .NET SDK 10 (this repo pins the `10.0.202` feature band via `global.json`) ## Local build -Build and pack the managed package: +Build the native wrapper, managed package, sample, and NuGet: -- `dotnet build src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release` -- `dotnet pack src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release -o artifacts/nuget` +```bash +dotnet workload install ios --version 10.0.202 +bash src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/build.sh +dotnet build src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release +dotnet build samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj -c Release +dotnet pack src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release -o artifacts/nuget +python3 scripts/validate-nupkg.py artifacts/nuget/Kapusch.StoreKit2.iOS.0.1.0.nupkg +``` -When native wrapper work is enabled, also run: +The native build is required even for managed-only package changes because the `.nupkg` must contain +the complete `xcframework`. -- `bash src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/build.sh` +## Branches and pull requests + +- `master` is the only long-lived development and release branch. +- Create a short-lived feature branch and open a pull request to `master`. +- Do not implement work directly on `release/*`, create new release branches, rebase published + history, force-push shared branches, or move an existing tag. +- Keep pull requests focused, update documentation when behavior changes, and merge only after CI + passes. + +## Releases and versions + +- Release candidates use `vX.Y.Z-rc.N`; stable releases use `vX.Y.Z`. +- Every release tag target must be reachable from `origin/master`. +- A manual workflow run without a version publishes a unique preview to GitHub Packages. +- A manual NuGet.org version publish must be dispatched from `master`; the workflow rejects versions + already present in the official NuGet index. +- Historical packaging requires the explicit `source_ref` input, which must resolve to a commit + reachable from `origin/master`. + +The complete deterministic procedure is in [`Docs/Release.md`](Docs/Release.md). ## Formatting - C#: follow `.editorconfig`. - Swift/Shell: keep style minimal and consistent. -## Pull requests - -- Keep PRs focused and scoped. -- Do not commit secrets. -- Update docs when public API changes. - ## License By contributing, you agree that your contributions are licensed under the repository license (MIT). diff --git a/Docs/Release.md b/Docs/Release.md new file mode 100644 index 0000000..4c0f77d --- /dev/null +++ b/Docs/Release.md @@ -0,0 +1,95 @@ +# Release workflow + +This repository uses one long-lived branch: `master`. All development happens on short-lived +feature branches and reaches `master` through pull requests with passing CI. Release branches are +not part of the workflow, and work must not be implemented directly on `release/*`. + +## Version and publication semantics + +The `publish` workflow has three modes: + +| Trigger | Version | Destination | Required source provenance | +| --- | --- | --- | --- | +| Push `vX.Y.Z-rc.N` | `X.Y.Z-rc.N` | NuGet.org | Tag target is reachable from `origin/master` | +| Push `vX.Y.Z` | `X.Y.Z` | NuGet.org | Tag target is reachable from `origin/master` | +| Manual, no `manual_version` | `-preview..` | GitHub Packages | Trigger commit, or an explicit reachable `source_ref` | +| Manual, with `manual_version` | `manual_version` | NuGet.org | Dispatch from `master`; source commit is reachable from `origin/master` | + +NuGet.org versions must use `X.Y.Z` or `X.Y.Z-rc.N`. The project `` is only the base for +automatically named previews; a release version comes from the tag or the explicit manual input. + +The workflow queries the official NuGet V3 flat-container index before any NuGet.org push. If the +version already exists, the workflow stops. It does not use `--skip-duplicate`, so an immutable +version can never be treated as a successful republish. + +## Normal tagged release + +1. Merge the release changes to `master` and wait for the `ci` workflow on the merged commit. +2. Fetch and verify the exact source: + + ```bash + git fetch --prune origin + git switch master + git merge --ff-only origin/master + git status --short --branch + ``` + +3. Check that the intended version is absent from + `https://api.nuget.org/v3-flatcontainer/kapusch.storekit2.ios/index.json`. +4. Create a new annotated `vX.Y.Z-rc.N` or `vX.Y.Z` tag on the selected `master` commit and push the + new tag. Never reuse or move an existing tag. +5. Verify the publish run. Its artifact contains the `.nupkg` and `package-provenance.json` with the + source SHA, trigger ref, destination, workflow run URL, and package SHA-256. + +## Previews and manual versions + +Dispatch `publish` without `manual_version` to create a GitHub Packages preview. Preview versions +include the workflow run number and source commit abbreviation so they are unique. + +A manual NuGet.org version is an explicit recovery or operator workflow, not a way to bypass tags: + +- dispatch the workflow from `master`; +- supply an unused `manual_version`; +- leave `source_ref` empty to package the selected `master` commit; or +- supply `source_ref` to package a historical commit already reachable from `origin/master`. + +The workflow never creates or moves a tag. If `v` already exists, its target must +match the resolved package source exactly. + +## Historical source + +Use `source_ref` only when the current workflow source is not the code that must be packaged. Prefer +an immutable full commit SHA or an existing tag. The workflow resolves the input, logs the checked-out +commit, and requires it to be an ancestor of `origin/master`. A deleted release branch name is not +durable provenance; its retained commit or tag is. + +## Required validation + +Run these commands on macOS with Xcode 26.3 selected before release work is merged: + +```bash +dotnet workload install ios --version 10.0.202 +bash src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/build.sh +dotnet build src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release +dotnet build samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj -c Release +dotnet pack src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release -o artifacts/nuget +python3 scripts/validate-nupkg.py artifacts/nuget/Kapusch.StoreKit2.iOS.0.1.0.nupkg +ruby -e 'require "yaml"; Dir[".github/workflows/*.{yml,yaml}"].each { |path| YAML.parse_file(path); puts "OK: #{path}" }' +``` + +CI repeats the native build, managed build, sample build, pack, and package-layout validation on pull +requests and on pushes to `master`. + +## Retiring a legacy release branch + +Preserve recovery refs locally, merge all retained commits through a pull request to `master`, and +wait for both PR CI and post-merge `master` CI. After fetching the merged state, the following must +report zero commits before deleting the legacy remote branch: + +```bash +git fetch --prune origin +git rev-list --count origin/master..origin/release/1.0.0 +``` + +Only then may `release/1.0.0` be deleted remotely and locally. Branch deletion must not delete or +retarget any historical tag; tags remain the permanent package provenance record. diff --git a/README.md b/README.md index 67ce968..fe5d81a 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,28 @@ A NuGet package that: - `src/Kapusch.StoreKit2ApisForiOSComponents/` - NuGet project (managed API + buildTransitive) - `src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/` - Swift interop source and build scripts +- `samples/Kapusch.StoreKit2.iOS.Sample/` - source-mode iOS build sample - `Docs/` - integration and source-mode notes +## Development and releases + +`master` is the only long-lived branch. Make changes on short-lived feature branches and merge them +through pull requests after CI passes. Do not implement work directly on `release/*` branches. + +- `vX.Y.Z-rc.N` and `vX.Y.Z` tags publish immutable packages to NuGet.org. Both tag forms must point + to commits reachable from `master`. +- A manual publish with no version creates a unique preview on GitHub Packages. +- A manual version publish is allowed only when the workflow is dispatched from `master` and the + version does not already exist on NuGet.org. + +See [`Docs/Release.md`](Docs/Release.md) for versioning, historical-source, validation, and package +provenance rules. + ## Build (local) Prerequisites: -- macOS with Xcode installed -- .NET SDK 10 (`global.json` pins `10.0.100`) +- macOS with Xcode 26.3 installed +- .NET SDK 10 (`global.json` pins the `10.0.202` feature band) Build native wrapper: - `bash src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/build.sh` @@ -31,6 +46,10 @@ Build native wrapper: Pack NuGet: - `dotnet pack src/Kapusch.StoreKit2ApisForiOSComponents/Kapusch.StoreKit2ApisForiOSComponents.csproj -c Release -o artifacts/nuget` +Validate the package and sample: +- `python3 scripts/validate-nupkg.py artifacts/nuget/Kapusch.StoreKit2.iOS.0.1.0.nupkg` +- `dotnet build samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj -c Release` + ## License MIT diff --git a/global.json b/global.json index 54b6d85..b068d40 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100", + "version": "10.0.202", "rollForward": "patch" } } diff --git a/samples/Kapusch.StoreKit2.iOS.Sample/AppDelegate.cs b/samples/Kapusch.StoreKit2.iOS.Sample/AppDelegate.cs new file mode 100644 index 0000000..9ead24b --- /dev/null +++ b/samples/Kapusch.StoreKit2.iOS.Sample/AppDelegate.cs @@ -0,0 +1,34 @@ +using Foundation; +using Kapusch.StoreKit2.iOS; +using UIKit; + +namespace Kapusch.StoreKit2.iOS.Sample; + +[Register("AppDelegate")] +public sealed class AppDelegate : UIApplicationDelegate +{ + private readonly IStoreKit2BillingClient _billingClient = new StoreKit2BillingClient(); + + public override UIWindow? Window { get; set; } + + public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions) + { + Window = new UIWindow(UIScreen.MainScreen.Bounds); + + var viewController = new UIViewController(); + viewController.View!.BackgroundColor = UIColor.SystemBackground; + + var label = new UILabel(viewController.View.Bounds) + { + AutoresizingMask = UIViewAutoresizing.FlexibleDimensions, + Lines = 0, + Text = $"StoreKit2 sample ready. Client: {_billingClient.GetType().Name}", + TextAlignment = UITextAlignment.Center, + }; + + viewController.View.AddSubview(label); + Window.RootViewController = viewController; + Window.MakeKeyAndVisible(); + return true; + } +} diff --git a/samples/Kapusch.StoreKit2.iOS.Sample/Info.plist b/samples/Kapusch.StoreKit2.iOS.Sample/Info.plist new file mode 100644 index 0000000..b32d277 --- /dev/null +++ b/samples/Kapusch.StoreKit2.iOS.Sample/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDisplayName + Kapusch StoreKit2 iOS Sample + CFBundleIdentifier + com.kapusch.storekit2ios.sample + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + KapuschStoreKit2iOSSample + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + MinimumOSVersion + 15.0 + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj b/samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj new file mode 100644 index 0000000..6950935 --- /dev/null +++ b/samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj @@ -0,0 +1,20 @@ + + + net10.0-ios + Exe + enable + enable + 15.0 + com.kapusch.storekit2ios.sample + Kapusch StoreKit2 iOS Sample + true + $(NoWarn);CA1422 + + + + + + diff --git a/samples/Kapusch.StoreKit2.iOS.Sample/Main.cs b/samples/Kapusch.StoreKit2.iOS.Sample/Main.cs new file mode 100644 index 0000000..0eeb59e --- /dev/null +++ b/samples/Kapusch.StoreKit2.iOS.Sample/Main.cs @@ -0,0 +1,4 @@ +using Kapusch.StoreKit2.iOS.Sample; +using UIKit; + +UIApplication.Main(args, null, typeof(AppDelegate)); diff --git a/samples/README.md b/samples/README.md index ae25ad2..97eb7b1 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,5 +1,12 @@ -# Samples +# Sample -This folder can host optional sample apps for local validation. +`Kapusch.StoreKit2.iOS.Sample` is a minimal iOS app that references the package project in source +mode. It intentionally does not perform a purchase; its purpose is to prove that the managed API, +native wrapper reference, and iOS application link together. -No sample is shipped yet. +Build the native wrapper first, then build the sample: + +```bash +bash src/Kapusch.StoreKit2ApisForiOSComponents/Native/iOS/build.sh +dotnet build samples/Kapusch.StoreKit2.iOS.Sample/Kapusch.StoreKit2.iOS.Sample.csproj -c Release +``` diff --git a/scripts/validate-nupkg.py b/scripts/validate-nupkg.py new file mode 100644 index 0000000..32d727c --- /dev/null +++ b/scripts/validate-nupkg.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Validate the durable contents and layout of a StoreKit2 NuGet package.""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import sys +import zipfile + + +REQUIRED_PATHS = { + "buildTransitive/Kapusch.StoreKit2.iOS.props", + "buildTransitive/Kapusch.StoreKit2.iOS.targets", + "kstorekit2.xcframework/Info.plist", + "nuget-readme.md", +} + + +def validate(package_path: Path) -> None: + if not package_path.is_file(): + raise ValueError(f"package does not exist: {package_path}") + + with zipfile.ZipFile(package_path) as package: + names = set(package.namelist()) + + missing = sorted(REQUIRED_PATHS - names) + if missing: + formatted = "\n".join(f" - {item}" for item in missing) + raise ValueError(f"missing required package paths:\n{formatted}") + + if "Info.plist" in names: + raise ValueError("wrapper is flattened: found Info.plist at the package root") + + if not any( + name.startswith("kstorekit2.xcframework/") and name.endswith(".a") for name in names + ): + raise ValueError("xcframework contains no static library") + + if not any( + name.startswith("lib/net10.0-ios") and name.endswith("/Kapusch.StoreKit2.iOS.dll") + for name in names + ): + raise ValueError("managed net10.0-ios assembly is missing") + + digest = hashlib.sha256(package_path.read_bytes()).hexdigest() + print(f"OK: {package_path}") + print(f"SHA256: {digest}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("package", type=Path, help="Path to the .nupkg to validate") + args = parser.parse_args() + + try: + validate(args.package) + except (OSError, ValueError, zipfile.BadZipFile) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())