17 Commits
Author SHA1 Message Date
Keith Solomon 93af230406 Make asset upload resilient (warn on duplicate, don't fail)
Release / Build & publish plugin zip (push) Successful in 6s
2026-08-11 13:48:03 -05:00
Keith Solomon c98e46437e Make release creation idempotent (lookup existing release by tag first)
Release / Build & publish plugin zip (push) Successful in 6s
2026-08-11 13:46:16 -05:00
Keith Solomon 3bae89a203 Extract release JSON building into helper script (avoid YAML/bash heredoc trap)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:41:38 -05:00
Keith Solomon 123c0dbb7b Fix bash heredoc-inside-command-substitution by writing to temp file
Release / Build & publish plugin zip (push) Failing after 6s
2026-08-11 13:27:24 -05:00
Keith Solomon 57e1f63bcf Use heredoc for embedded Python (fix IndentationError)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:24:44 -05:00
Keith Solomon bea4875b05 Branch release step on host (github.com vs Gitea)
Release / Build & publish plugin zip (push) Failing after 6s
2026-08-11 13:18:49 -05:00
Keith Solomon 343f647770 Call Gitea REST API directly for releases (Gitea Actions compat)
Release / Build & publish plugin zip (push) Successful in 6s
2026-08-11 13:15:31 -05:00
Keith Solomon 7184d998e6 Use gh CLI for release creation (GHES compatibility)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:08:40 -05:00
Keith Solomon 189bde2008 Use upload-artifact@v3 for GHES compatibility
Release / Build & publish plugin zip (push) Failing after 35s
2026-08-11 13:05:35 -05:00
Keith Solomon a090527037 Use Python zipfile in build step (zip CLI not on runner)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:00:49 -05:00
Keith Solomon 1595327a80 Point old 'How to Zip' section to automated workflow
Release / Build & publish plugin zip (push) Failing after 37s
2026-08-11 07:00:44 -05:00
Keith Solomon b4b6158f2d Fix double-v in artifact name 2026-08-11 07:00:28 -05:00
Keith Solomon 0e56de3c56 Bump version to 1.1.1 for first automated release 2026-08-11 06:52:25 -05:00
Keith Solomon f9b8611519 Document automated release process in README 2026-08-11 06:50:23 -05:00
Keith Solomon 84e5119c41 Add GitHub Actions release workflow 2026-08-11 06:48:35 -05:00
Keith Solomon 67a28e0037 Add release workflow implementation plan
4-task plan: workflow file, README section, version bump to 1.1.1,
final verification. No PHP behavior changes; PHPUnit suite unaffected.
2026-08-11 06:45:10 -05:00
Keith Solomon fb6c9d4d61 Add design spec: GitHub Actions release-zip workflow
Single workflow file at .github/workflows/release.yml that triggers
on v* tag pushes, assembles projects-portfolio-v<version>.zip from
an allowlist of runtime files, uploads as a workflow artifact, and
attaches to a matching GitHub release.
2026-08-11 06:43:47 -05:00
6 changed files with 716 additions and 12 deletions
+157
View File
@@ -0,0 +1,157 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Build & publish plugin zip
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build plugin zip
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
OUT="dist/projects-portfolio-v${VERSION}.zip"
rm -rf dist
mkdir -p dist
# Use Python's stdlib zipfile module — guaranteed to be on ubuntu-latest.
python3 - "$OUT" <<'PYEOF'
import os
import sys
import zipfile
out_path = sys.argv[1]
allowed = [
"projects-portfolio.php",
"README.md",
"LICENSE",
"admin",
"assets",
"includes",
"languages",
"templates",
]
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for entry in allowed:
if os.path.isdir(entry):
for root, _, files in os.walk(entry):
for name in files:
abs_path = os.path.join(root, name)
arcname = os.path.join("projects-portfolio", abs_path)
zf.write(abs_path, arcname)
elif os.path.isfile(entry):
zf.write(entry, os.path.join("projects-portfolio", entry))
else:
sys.exit(f"Allowlisted path missing: {entry}")
PYEOF
echo "Built $OUT"
python3 -c "import zipfile; zf=zipfile.ZipFile('$OUT'); print('\n'.join(zf.namelist()))"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: projects-portfolio-${{ github.ref_name }}
path: dist/projects-portfolio-*.zip
if-no-files-found: error
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
GH_API_URL: ${{ github.api_url }}
GH_SHA: ${{ github.sha }}
run: |
set -euo pipefail
zip_path=( dist/projects-portfolio-*.zip )
zip_path="${zip_path[0]}"
asset_name=$(basename "$zip_path")
if [[ "$GH_API_URL" == "https://api.github.com" ]]; then
# ---- GitHub.com path ----
if [ -z "${GITHUB_TOKEN:-}" ]; then
echo "::error::GITHUB_TOKEN is not available on this runner. Check repo permissions." >&2
exit 1
fi
payload=$(python3 scripts/release-helper.py build-payload --github)
# Look up existing release by tag (idempotent on re-run).
release_json=$(curl -fsS \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${GH_API_URL}/repos/${REPO}/releases/tags/${TAG}" \
|| true)
if [ -n "$release_json" ] && echo "$release_json" | python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.stdin.read()).get("id") else 1)' 2>/dev/null; then
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
upload_url=$(echo "$release_json" | python3 scripts/release-helper.py extract-upload-url)
echo "Reusing existing GitHub release id=$release_id for tag $TAG"
else
release_json=$(curl -fsS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
-d "$payload" \
"${GH_API_URL}/repos/${REPO}/releases")
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
upload_url=$(echo "$release_json" | python3 scripts/release-helper.py extract-upload-url)
echo "Created GitHub release id=$release_id"
fi
curl -fsS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/zip" \
--data-binary "@${zip_path}" \
"${upload_url}?name=${asset_name}"
echo "Uploaded GitHub asset: ${asset_name}"
else
# ---- Gitea path ----
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::GITEA_TOKEN secret is not set. Create a personal access token in Gitea with 'write:repository' scope and add it as a repository secret named GITEA_TOKEN." >&2
exit 1
fi
if [ -z "${GH_API_URL:-}" ] || [[ "$GH_API_URL" == "https://git.keithsolomon.net"* ]]; then
API="https://git.keithsolomon.net/api/v1"
else
API="${GH_API_URL%/}/api/v1"
fi
payload=$(python3 scripts/release-helper.py build-payload)
# Look up existing release by tag (idempotent on re-run).
release_json=$(curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|| true)
if [ -n "$release_json" ] && echo "$release_json" | python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.stdin.read()).get("id") else 1)' 2>/dev/null; then
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
echo "Reusing existing Gitea release id=$release_id for tag $TAG"
else
release_json=$(curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" \
"${API}/repos/${REPO}/releases")
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
echo "Created Gitea release id=$release_id"
fi
curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${zip_path}" \
"${API}/repos/${REPO}/releases/${release_id}/assets?name=${asset_name}" \
|| echo "::warning::Asset upload failed (asset may already exist); continuing."
echo "Gitea asset step complete: ${asset_name}"
fi
+18 -10
View File
@@ -121,18 +121,26 @@ To avoid rate limits or improve reliability:
1. In your Gitea instance, go to **Settings → Applications** and generate a token.
2. Paste it into the plugin settings screen under **Gitea API Token**.
## Building a Release
Releases are automated via GitHub Actions. To cut a new release:
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
2. Commit and push to `main`.
3. Tag the release commit: `git tag -a v1.1.1 -m "v1.1.1 — short summary"`.
4. Push the tag: `git push origin v1.1.1`.
The `.github/workflows/release.yml` workflow runs and:
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
- Uploads the zip as a workflow artifact.
- Creates (or updates) the matching GitHub Release with the zip attached.
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
## How to Zip the Plugin for GitHub Releases
When attaching the plugin to a GitHub release:
1. Zip **only the contents of the plugin folder**, not the parent folder.
2. Ensure `projects-portfolio.php` and `/includes`, `/admin`, `/templates`, `/assets` are at the root level of the ZIP.
3. Name the ZIP clearly (e.g. `projects-portfolio-1.0.0.zip`).
4. Go to your GitHub repo → **Releases****New Release**.
5. Tag the release with the version number (e.g., `1.0.0`).
6. Upload your correctly structured ZIP as a release asset.
Your users will be redirected to this file when they use the `/download/` endpoint.
This is now automated. See [Building a Release](#building-a-release) above — pushing a `v*` tag triggers `.github/workflows/release.yml` which builds and publishes the zip.
## REST API Endpoints
+293
View File
@@ -0,0 +1,293 @@
# Release Workflow Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a GitHub Actions workflow that, on every `v*` tag push, builds `projects-portfolio-v<version>.zip` from an allowlist of runtime files and publishes it both as a workflow artifact and as an attachment to a matching GitHub release.
**Architecture:** Single workflow file at `.github/workflows/release.yml`. One job (`release`) on `ubuntu-latest` performs: checkout → bash build step (allowlisted `cp -r` into a staging dir, then `zip -r`) → `actions/upload-artifact@v4``softprops/action-gh-release@v2`. Uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission.
**Tech Stack:** GitHub Actions YAML, bash, `zip` CLI (preinstalled on `ubuntu-latest`), `actions/checkout@v4`, `actions/upload-artifact@v4`, `softprops/action-gh-release@v2`.
## Global Constraints
- Workflow file lives at `.github/workflows/release.yml`.
- Trigger: `on.push.tags: ['v*']`. No `workflow_dispatch`.
- Permission: `contents: write`.
- Zip filename: `projects-portfolio-v<version>.zip` where `<version>` is `GITHUB_REF_NAME` with leading `v` stripped.
- Zip internal layout: `projects-portfolio/` directory at zip root, with `projects-portfolio.php` inside it (WordPress install-from-zip convention).
- Allowlisted source paths copied into staging: `projects-portfolio.php`, `README.md`, `LICENSE`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`.
- Excluded paths (no `cp` of these): `composer.json`, `composer.lock`, `vendor/`, `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`.
- Build step uses `set -euo pipefail` and an `unzip -l "$OUT"` debug print at the end so the action log self-documents the contents.
- Artifact upload uses `if-no-files-found: error` so a missing zip fails the job.
- `softprops/action-gh-release@v2` is given the tag name, release name = tag name, `generate_release_notes: true`, and `files:` pointing at the zip.
- README gets a "Building a Release" section after "Connect Your Gitea Repo".
- First release is `v1.1.1`: implementation includes a version bump from `1.1.0``1.1.1` (header `Version:` + `PROJECTS_PORTFOLIO_VERSION` constant).
- No PHP files outside of the version bump are touched. The PHPUnit suite is unaffected.
## File Structure
**Created:**
- `.github/workflows/release.yml` — the workflow.
**Modified:**
- `README.md` — adds a "Building a Release" section after the existing "Connect Your Gitea Repo" section.
- `projects-portfolio.php` — bumps header `Version:` and `PROJECTS_PORTFOLIO_VERSION` from `1.1.0` to `1.1.1` as part of the first-release rollout.
No other files touched. No tests added.
---
## Task 1: Add the release workflow file
**Files:**
- Create: `.github/workflows/release.yml`
**Interfaces:**
- Produces: a workflow that triggers on `v*` tag push and produces a release zip + GitHub release.
- [ ] **Step 1: Create `.github/workflows/release.yml`**
Create the directory if missing, then create the file with this exact content:
```yaml
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Build & publish plugin zip
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build plugin zip
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
STAGE="dist/staging"
OUT="dist/projects-portfolio-v${VERSION}.zip"
rm -rf dist
mkdir -p "$STAGE/projects-portfolio"
cp projects-portfolio.php "$STAGE/projects-portfolio/"
cp README.md "$STAGE/projects-portfolio/"
cp LICENSE "$STAGE/projects-portfolio/"
cp -r admin/ "$STAGE/projects-portfolio/"
cp -r assets/ "$STAGE/projects-portfolio/"
cp -r includes/ "$STAGE/projects-portfolio/"
cp -r languages/ "$STAGE/projects-portfolio/"
cp -r templates/ "$STAGE/projects-portfolio/"
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
echo "Built $OUT"
unzip -l "$OUT"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: projects-portfolio-v${{ github.ref_name }}
path: dist/projects-portfolio-*.zip
if-no-files-found: error
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: dist/projects-portfolio-*.zip
```
- [ ] **Step 2: Validate YAML syntax locally**
Run from the project root:
```bash
php -r "require 'vendor/autoload.php';" 2>/dev/null || true
# PHP doesn't have a built-in YAML parser; use Python's if available
python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml').read()); print('OK')"
```
If `python` is not on PATH, use any other YAML validator you have. If none is available, skip this step — GitHub's own workflow validation will catch syntax errors on the next push.
Expected (if `python` is available): `OK` on stdout, exit 0.
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/release.yml
git commit -m "Add GitHub Actions release workflow"
```
---
## Task 2: Add "Building a Release" section to README
**Files:**
- Modify: `README.md`
**Interfaces:**
- Produces: a "Building a Release" section placed after the "Connect Your Gitea Repo" section (which was added in the previous feature).
- [ ] **Step 1: Insert the new section**
Find the existing `## Connect Your Gitea Repo` section in `README.md`. Locate the heading `## Building a Release` (it does not yet exist; that's the insertion target).
Insert the following block directly after the closing line of the Gitea repo subsection (`Paste it into the plugin settings screen under **Gitea API Token**.`) and before any subsequent `## ` heading:
```markdown
## Building a Release
Releases are automated via GitHub Actions. To cut a new release:
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
2. Commit and push to `main`.
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
4. Push the tag: `git push origin v1.1.0`.
The `.github/workflows/release.yml` workflow runs and:
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
- Uploads the zip as a workflow artifact.
- Creates (or updates) the matching GitHub Release with the zip attached.
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
```
- [ ] **Step 2: Verify with grep**
Run:
```bash
grep -n "Building a Release" README.md
```
Expected: one matching line, located after the "Connect Your Gitea Repo" section.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "Document automated release process in README"
```
---
## Task 3: Bump version to 1.1.1 for first automated release
**Files:**
- Modify: `projects-portfolio.php` — header `Version:` and `PROJECTS_PORTFOLIO_VERSION` constant.
**Interfaces:**
- Produces: in-tree version `1.1.1`, header comment line `* Version: 1.1.1`, constant `define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );`.
- [ ] **Step 1: Update the plugin header**
In `projects-portfolio.php`, in the file header comment block near the top, change:
```
* Version: 1.1.0
```
to:
```
* Version: 1.1.1
```
- [ ] **Step 2: Update the version constant**
In `projects-portfolio.php`, find the line:
```php
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
```
Replace with:
```php
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );
```
- [ ] **Step 3: Run the test suite to confirm no regression**
Run: `php vendor/bin/phpunit`
Expected: `OK (24 tests, 45 assertions)` (same as before — no PHP behavior change).
- [ ] **Step 4: Verify with grep**
Run:
```bash
grep -n "Version:" projects-portfolio.php | head -1
grep -n "PROJECTS_PORTFOLIO_VERSION" projects-portfolio.php
```
Expected output:
- First grep: a line containing `* Version: 1.1.1`.
- Second grep: two lines — the `define(...)` and (possibly) usage sites. The `define` line must show `'1.1.1'`.
- [ ] **Step 5: Commit**
```bash
git add projects-portfolio.php
git commit -m "Bump version to 1.1.1 for first automated release"
```
---
## Task 4: Final verification
**Files:** none (verification only).
- [ ] **Step 1: Confirm all three commits landed**
Run:
```bash
git log --oneline -5
```
Expected (most recent at top): three commits from this plan are visible:
- "Bump version to 1.1.1 for first automated release"
- "Document automated release process in README"
- "Add GitHub Actions release workflow"
- [ ] **Step 2: Confirm working tree is clean**
Run: `git status`
Expected: `nothing to commit, working tree clean`.
- [ ] **Step 3: Run full PHPUnit suite**
Run: `php vendor/bin/phpunit`
Expected: `OK (24 tests, 45 assertions)`.
- [ ] **Step 4: Confirm workflow file content**
Run:
```bash
head -30 .github/workflows/release.yml
```
Expected: matches the YAML produced in Task 1, with `name: Release`, `on.push.tags: ['v*']`, `permissions.contents: write`, and the `release` job.
- [ ] **Step 5: Confirm README has the new section**
Run:
```bash
grep -c "Building a Release" README.md
```
Expected: `1`.
- [ ] **Step 6: Note (do NOT execute): the first automated release**
The user will tag `v1.1.1` and push the tag themselves, which triggers the workflow. The workflow file is in place; the in-tree version is bumped; the first release will produce a zip with this code. The implementer does NOT push the tag or the branch — that's the user's call (their existing git history is ahead of `origin/main`).
- [ ] **Step 7: Final commit if anything changed**
```bash
git status
# If clean, skip. Otherwise:
git add -A
git commit -m "Final verification fixes"
```
+2 -2
View File
@@ -11,7 +11,7 @@
* Plugin Name: Projects Portfolio
* Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.
* Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
* Version: 1.1.0
* Version: 1.1.1
* Author: Keith Solomon
* Author URI: https://keithsolomon.net
* License: GPL-2.0+
@@ -39,7 +39,7 @@ $myUpdateChecker = PucFactory::buildUpdateChecker(
$myUpdateChecker->setBranch( 'main' );
// Current plugin version.
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );
// Add the required files.
require 'admin/admin-settings.php';
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Helper script for the release workflow (.github/workflows/release.yml).
Subcommands:
build-payload [--github] Build the JSON payload for creating a release.
Reads TAG, GH_SHA from env.
With --github, includes generate_release_notes=True.
Writes JSON to stdout.
extract-id < response.json Print the release id from a release API response.
extract-upload-url < response.json Print the upload_url from a GitHub release
response (used as the asset upload endpoint template).
This script exists to avoid bash heredoc-inside-YAML-literal indentation traps.
"""
import json
import os
import sys
def build_payload(github: bool) -> int:
tag = os.environ.get("TAG", "")
sha = os.environ.get("GH_SHA", "unknown")
payload = {
"tag_name": tag,
"name": tag,
"body": (
"Automated release.\n\n"
f"Built from commit {sha}.\n\n"
"See the workflow run for the artifact."
),
"draft": False,
"prerelease": False,
}
if github:
payload["generate_release_notes"] = True
print(json.dumps(payload))
return 0
def extract_field(field: str) -> int:
raw = sys.stdin.read()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print(f"::error::Failed to parse JSON response: {e}", file=sys.stderr)
return 1
value = data.get(field, "")
if not value:
print(f"::error::Field {field!r} missing from response: {raw}", file=sys.stderr)
return 1
print(value)
return 0
def usage() -> int:
print(__doc__, file=sys.stderr)
return 2
def main() -> int:
if len(sys.argv) < 2:
return usage()
cmd = sys.argv[1]
if cmd == "build-payload":
return build_payload(github="--github" in sys.argv)
if cmd == "extract-id":
return extract_field("id")
if cmd == "extract-upload-url":
return extract_field("upload_url")
if cmd == "extract-tag":
return extract_field("tag_name")
return usage()
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
# Release Workflow Design
- **Date:** 2026-08-11
- **Plugin:** Projects Portfolio (`projects-wp`)
- **Status:** Approved design, awaiting implementation plan
## 1. Goals
- A single tag push (`v*`) automatically produces a release-ready zip named `projects-portfolio-v<version>.zip`.
- The zip contains exactly the files WordPress needs to install and use the plugin: `projects-portfolio.php` at the root of `projects-portfolio/`, with `admin/`, `assets/`, `includes/`, `languages/`, `templates/` subdirectories plus `README.md` and `LICENSE`.
- The same zip is uploaded as a workflow artifact AND attached to a GitHub release matching the tag.
- README gets a short "Building a release" section explaining how the workflow runs and what it produces.
## 2. Non-goals
- Publishing to WordPress.org — out of scope (different credentials, different approval workflow).
- Cross-repo publishing or PAT-based auth.
- Signing, cosign, or checksums for the zip.
- Auto-bumping `PROJECTS_PORTFOLIO_VERSION` or the plugin header before a tag is cut. The workflow assumes the tag is created *after* the version has been bumped in-tree.
- Backporting tags. The workflow only acts on the tag push, not on retags of older SHAs.
## 3. Architecture
A single GitHub Actions workflow file at `.github/workflows/release.yml` runs on every `v*` tag push. It performs four sequential steps inside a single job:
1. **Checkout**`actions/checkout@v4` at the tag's commit.
2. **Build zip** — bash script copies allowlisted runtime files into a staging directory `dist/staging/projects-portfolio/`, then runs `zip -r` from the staging root to produce `dist/projects-portfolio-v<version>.zip`.
3. **Upload artifact**`actions/upload-artifact@v4` with name `projects-portfolio-v<tag>` and path `dist/projects-portfolio-*.zip`. Fails if no file matches.
4. **Create release**`softprops/action-gh-release@v2` with the tag, generated release notes, and the zip file. Replaces the asset if a release with the same tag already exists (default `action-gh-release@v2` behavior).
The workflow uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission. No additional secrets are needed.
### 3.1 Why a staging directory
WordPress's "Upload Plugin" zip-install expects `<plugin-slug>/<plugin-file>.php` at the zip root. A direct `zip -r` of the working tree would put everything at the zip root — no enclosing folder — which would fail to install. The staging directory ensures the zip's internal layout has `projects-portfolio/` at the top with `projects-portfolio.php` inside it.
### 3.2 Files added
- `.github/workflows/release.yml` — the workflow.
- `README.md` — modified, with a new "Building a release" section after the existing "Connect Your Gitea Repo" section.
No PHP files are touched. The PHPUnit suite is unaffected (no test bootstrap or production code changes).
## 4. Trigger & permissions
```yaml
on:
push:
tags:
- 'v*'
permissions:
contents: write
```
`workflow_dispatch` is intentionally omitted. To re-run for an existing tag, use the GitHub UI's "Re-run all jobs" button.
## 5. Zip filename
`projects-portfolio-v<version>.zip`, where `<version>` is the tag name with the leading `v` stripped. Examples:
- Tag `v1.1.0``projects-portfolio-v1.1.0.zip`
- Tag `v2.0.0-rc1``projects-portfolio-v2.0.0-rc1.zip`
## 6. Allowlisted zip contents
The build step copies these paths from the working tree into `dist/staging/projects-portfolio/`:
```
projects-portfolio.php
README.md
LICENSE
admin/
assets/
includes/
languages/
templates/
```
The bundled `includes/plugin-update-checker/` library IS included — it's a runtime dependency used by `projects-portfolio.php`.
## 7. Excluded paths
The build step does **not** copy any of these. Most are already not tracked in git; the explicit list prevents accidental inclusion if any are added later:
- `composer.json`, `composer.lock` — dev-only.
- `vendor/` — Composer dev dependencies.
- `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache` — test infrastructure.
- `plans/`, `specs/` — design artifacts (not tracked, but listed for safety).
- `.github/` — the workflow file itself (would otherwise be included by the catch-all `cp -r`).
- `.gitignore`, `.vscode/`, `.claude/` — editor / project config (not tracked except `.gitignore`).
If the build script can't find a tracked source path it expects (e.g. `projects-portfolio.php` renamed), `set -e` fails the step.
## 8. Build step (verbatim)
```bash
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
STAGE="dist/staging"
OUT="dist/projects-portfolio-v${VERSION}.zip"
rm -rf dist
mkdir -p "$STAGE/projects-portfolio"
cp projects-portfolio.php "$STAGE/projects-portfolio/"
cp README.md "$STAGE/projects-portfolio/"
cp LICENSE "$STAGE/projects-portfolio/"
cp -r admin/ "$STAGE/projects-portfolio/"
cp -r assets/ "$STAGE/projects-portfolio/"
cp -r includes/ "$STAGE/projects-portfolio/"
cp -r languages/ "$STAGE/projects-portfolio/"
cp -r templates/ "$STAGE/projects-portfolio/"
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
echo "Built $OUT"
unzip -l "$OUT"
```
`unzip -l` prints the table of contents to the action log so the run is self-documenting.
## 9. README addition
Add a "Building a Release" section after the existing "Connect Your Gitea Repo" section:
```markdown
## Building a Release
Releases are automated via GitHub Actions. To cut a new release:
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
2. Commit and push to `main`.
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
4. Push the tag: `git push origin v1.1.0`.
The `.github/workflows/release.yml` workflow runs and:
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
- Uploads the zip as a workflow artifact.
- Creates (or updates) the matching GitHub Release with the zip attached.
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
```
## 10. Error handling
| Failure | Behavior |
|---|---|
| Tag doesn't match `v*` | Workflow doesn't run. |
| `cp -r` finds a missing source path | `set -e` fails the step; no zip, no release. |
| `zip` command fails | `set -e` fails the step; no artifact upload, no release. |
| Artifact upload finds no file | `if-no-files-found: error` fails the step. |
| Release already exists for tag | `softprops/action-gh-release@v2` replaces the asset and updates notes (default behavior). |
| Missing `contents: write` permission | Job fails at checkout or release step with a clear permissions error. |
## 11. Rollout
1. Implement on a feature branch.
2. Merge into `main` (the workflow file is tracked and applies to all future tags).
3. To produce the first automated release: bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header from `1.1.0` to `1.1.1`, commit, tag `v1.1.1`, push the tag.
4. Verify on the GitHub Actions run page that: artifact uploads, zip contents look right (via the `unzip -l` log line), GitHub release is created with the zip attached.
## 12. New translatable strings
None. The workflow file and README change introduce no user-facing strings.