Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
name: Release

# ---------------------------------------------------------------------------
# Tag-triggered release: build, verify, publish to PyPI, cut a GitHub Release.
#
# DO NOT RENAME THIS FILE. The workflow filename is part of the identity PyPI
# checks when it validates the OIDC token. Renaming it silently breaks
# publishing, and the resulting error does not point here.
#
# ONE-TIME SETUP, before the first tag (none of it lives in this repo):
#
# 1. PyPI -> your account sidebar -> Publishing -> add a *pending* publisher.
# It is under the account, not a project, because `da-cli` does not exist
# on PyPI yet; a pending publisher converts to a real one on first
# publish and does not reserve the name before then.
# owner: FZ2000 repo: da-cli workflow: release.yml environment: pypi
#
# 2. This repo -> Settings -> Environments -> new environment named `pypi`,
# with a deployment branch/tag rule allowing `v*` only.
#
# Do NOT add required reviewers on a solo project: "prevent self-review"
# is off by default so you would merely approve your own deploy, and
# turning it on would deadlock you. The environment earns its place for a
# different reason — PyPI refuses an OIDC token minted outside the
# registered environment, so the constraint is enforced by a third party
# rather than by your own discipline.
#
# 3. Settings -> Tags -> protect `v*` so only you can create one.
#
# WHY EVERY ACTION HERE IS SHA-PINNED: this is the only workflow that mints a
# publishing credential, so a moved tag on a third-party action is a direct
# path to a compromised release. Each pin carries a `# vX.Y.Z` comment because
# Renovate disables updates for a bare SHA it cannot attribute to a version —
# the comment is what keeps a pin maintained rather than merely frozen.
#
# This locks the OUTER reference only. A composite action that internally uses
# a mutable tag is still reachable, which is why the publish job does nothing
# but download an artifact and upload it.
#
# WHY OIDC AND NOT AN API TOKEN: a PyPI API token is valid indefinitely and
# lives in your repo secrets. An OIDC token is minted per-run and expires in
# 15 minutes, and there is nothing to leak in between.
# ---------------------------------------------------------------------------

on:
push:
tags: ["v*"]

# Least privilege at the top. The publish job raises `id-token` for itself
# only — GitHub's own docs discourage granting it workflow-wide.
permissions:
contents: read

jobs:
build:
name: Build and verify
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# This job needs no credentials; a checkout that leaves one behind
# is a needless one.
persist-credentials: false

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
cache: pip
cache-dependency-path: pyproject.toml

- name: Tag must match dacli.__version__
# The version is `dynamic` from dacli.__version__, so nothing
# otherwise couples it to the tag. Without this check you can ship
# v0.5.0 containing a package that reports 0.4.0, and PyPI will
# cheerfully accept it.
run: |
v=$(python -c 'import dacli; print(dacli.__version__)')
if [ "v$v" != "$GITHUB_REF_NAME" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match dacli.__version__ ($v)"
exit 1
fi
echo "::notice::tag $GITHUB_REF_NAME matches dacli.__version__"

- name: Build sdist and wheel
run: |
python -m pip install --upgrade pip build
rm -rf build dist ./*.egg-info
python -m build

- name: The wheel must work without the source tree
# Every other job runs `da` out of the checkout, where `import dacli`
# resolves to the source and therefore always works. That masks
# packaging faults completely — a wheel missing a subpackage passes
# all of them.
run: |
python -m venv /tmp/relcheck
/tmp/relcheck/bin/pip install --quiet dist/*.whl
/tmp/relcheck/bin/da --version
/tmp/relcheck/bin/da --help > /dev/null
/tmp/relcheck/bin/python - <<'PY'
import pathlib, pkgutil, importlib, dacli
assert (pathlib.Path(dacli.__file__).parent / "py.typed").exists(), "py.typed missing from the wheel"
for m in pkgutil.walk_packages(dacli.__path__, "dacli."):
importlib.import_module(m.name)
print("wheel imports cleanly, py.typed present")
PY

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
if-no-files-found: error

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
environment: pypi
permissions:
# Job-scoped, deliberately. This is the only job that mints an OIDC
# token, and it does nothing but download an artifact and upload it —
# keep it that way, so a compromised action elsewhere in CI has no path
# to a publishing credential.
id-token: write
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: dist
path: dist/

- name: Publish
# No username, no password, no token. Attestations (PEP 740, backed by
# Sigstore) are on by default for Trusted Publishing, so there is
# nothing extra to configure and no second signing step to maintain.
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

github-release:
name: GitHub Release
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # to create the release
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: dist
path: dist/
- name: Create the release
env:
GH_TOKEN: ${{ github.token }}
# --verify-tag refuses to invent a tag that does not already exist.
# Notes come from CHANGELOG.md rather than --generate-notes: generated
# notes are a list of merged pull requests, and a hand-written
# changelog says more than a list of PR titles.
run: |
notes=$(python3 - <<'PY'
import pathlib, re, os
tag = os.environ["GITHUB_REF_NAME"].lstrip("v")
text = pathlib.Path("CHANGELOG.md").read_text()
# `\n## ` is exactly two hashes on purpose: `##+` also matches the
# `### Added` subsection directly below the heading, which collapses
# the captured body to nothing.
m = re.search(rf"^## \[?{re.escape(tag)}\]?[^\n]*\n(.*?)(?=\n## |\Z)", text, re.S | re.M)
print(m.group(1).strip() if m else f"See CHANGELOG.md for {tag}.")
PY
)
gh release create "$GITHUB_REF_NAME" dist/* \
--verify-tag \
--title "$GITHUB_REF_NAME" \
--notes "$notes"
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,15 @@ cd ~/Documents/da-cli
./install.sh # copies da + the dacli package to ~/.local/share/da-cli/ and symlinks ~/.local/bin/da → that copy
```

**Option B — pip install** — *not yet available; da-cli is not published to
PyPI. Use Option A.* When it is published, this will work:
**Option B — pip install.** Not available: **da-cli is not published to
PyPI**, and the name is currently unregistered. Use Option A.

```bash
pipx install da-cli # or: pip install da-cli
da --version
```
Do not `pip install da-cli` on the strength of this README. Until the name
appears here as published, anything under it on PyPI is not this project —
and since this package installs a `da` command onto your `PATH` and handles
DeviantArt OAuth tokens, installing an impostor is not a harmless mistake.

Note also that `dacli` (no hyphen) on PyPI is an unrelated project.

Python 3.10+ required (uses `argparse.BooleanOptionalAction` and `X | None` syntax). No third-party runtime dependencies.

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Here's what each field means:
| **Title** | `da-cli` | Any name you like; this is just a label. |
| **Description** | *(leave blank)* | Optional; not used by da-cli. |
| **OAuth2 Redirect URI Whitelist** | `https://localhost:8765/` | This is the address da-cli listens on for the login callback. **Must match exactly** — including the trailing slash. |
| **Client type** | **Confidential** | Issues a `client_secret`, which da-cli stores in the macOS Keychain and sends on the token exchange. It is **optional** — every code path guards on it, and without one da-cli authenticates as a public client using PKCE, which is what DeviantArt's own form recommends for desktop apps. Choose Confidential if you want the secret; Public works too. |
| **Client type** | **Public** | da-cli is a desktop tool running on your machine, which is exactly the case DeviantArt's own form describes: *"Your app will authenticate with client_id and PKCE — no secret required."* Public means there is no `client_secret` to store, leak, or rotate. Choose **Confidential** only if you specifically want one; da-cli supports it (every code path guards on `client_secret` being present) but does not need it, and the screenshot below predates this recommendation. |
| **Download URL** | *(leave blank)* | Not used by da-cli. |
| **Original URLs Whitelist** | *(leave blank)* | Not used by da-cli. |

Expand Down
Loading