<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: continuous-integration</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/continuous-integration.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2025-06-27T23:31:11+00:00</updated><author><name>Simon Willison</name></author><entry><title>Continuous AI</title><link href="https://simonwillison.net/2025/Jun/27/continuous-ai/#atom-tag" rel="alternate"/><published>2025-06-27T23:31:11+00:00</published><updated>2025-06-27T23:31:11+00:00</updated><id>https://simonwillison.net/2025/Jun/27/continuous-ai/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://githubnext.com/projects/continuous-ai"&gt;Continuous AI&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
GitHub Next have coined the term "Continuous AI" to describe "all uses of automated AI to support software collaboration on any platform". It's intended as an echo of Continuous Integration and Continuous Deployment:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We've chosen the term "Continuous AI” to align with the established concept of Continuous Integration/Continuous Deployment (CI/CD). Just as CI/CD transformed software development by automating integration and deployment, Continuous AI covers the ways in which AI can be used to automate and enhance collaboration workflows.&lt;/p&gt;
&lt;p&gt;“Continuous AI” is not a term GitHub owns, nor a technology GitHub builds: it's a term we use to focus our minds, and which we're introducing to the industry. This means Continuous AI is an open-ended set of activities, workloads, examples, recipes, technologies and capabilities; a category, rather than any single tool.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I was thrilled to bits to see LLM get a mention as a tool that can be used to implement some of these patterns inside of GitHub Actions:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You can also use the &lt;a href="https://llm.datasette.io/en/stable/"&gt;llm framework&lt;/a&gt; in combination with the &lt;a href="https://github.com/tonybaloney/llm-github-models"&gt;llm-github-models extension&lt;/a&gt; to create LLM-powered GitHub Actions which use GitHub Models using Unix shell scripting.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The GitHub Next team have started maintaining an &lt;a href="https://github.com/githubnext/awesome-continuous-ai"&gt;Awesome Continuous AI&lt;/a&gt; list with links to projects that fit under this new umbrella term.&lt;/p&gt;
&lt;p&gt;I'm particularly interested in the idea of having CI jobs (I guess CAI jobs?) that check proposed changes to see if there's documentation that needs to be updated and that might have been missed - a much more powerful variant of my &lt;a href="https://simonwillison.net/2018/Jul/28/documentation-unit-tests/"&gt;documentation unit tests&lt;/a&gt; pattern.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="github"/><category term="ai"/><category term="github-actions"/><category term="generative-ai"/><category term="llms"/><category term="llm"/></entry><entry><title>cibuildwheel 2.20.0 now builds Python 3.13 wheels by default</title><link href="https://simonwillison.net/2024/Aug/6/cibuildwheel/#atom-tag" rel="alternate"/><published>2024-08-06T22:54:44+00:00</published><updated>2024-08-06T22:54:44+00:00</updated><id>https://simonwillison.net/2024/Aug/6/cibuildwheel/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/pypa/cibuildwheel/releases/tag/v2.20.0"&gt;cibuildwheel 2.20.0 now builds Python 3.13 wheels by default&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;CPython 3.13 wheels are now built by default […] This release includes CPython 3.13.0rc1, which is guaranteed to be ABI compatible with the final release.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://cibuildwheel.pypa.io/"&gt;cibuildwheel&lt;/a&gt; is an underrated but crucial piece of the overall Python ecosystem.&lt;/p&gt;
&lt;p&gt;Python wheel packages that include binary compiled components - packages with C extensions for example - need to be built multiple times, once for each combination of Python version, operating system and architecture.&lt;/p&gt;
&lt;p&gt;A package like Adam Johnson’s &lt;a href="https://github.com/adamchainz/time-machine"&gt;time-machine&lt;/a&gt; - which bundles a &lt;a href="https://github.com/adamchainz/time-machine/blob/main/src/_time_machine.c"&gt;500 line C extension&lt;/a&gt; - can end up with &lt;a href="https://pypi.org/project/time-machine/#files"&gt;55 different wheel files&lt;/a&gt; with names like &lt;code&gt;time_machine-2.15.0-cp313-cp313-win_arm64.whl&lt;/code&gt; and &lt;code&gt;time_machine-2.15.0-cp38-cp38-musllinux_1_2_x86_64.whl&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Without these wheels, anyone who runs &lt;code&gt;pip install time-machine&lt;/code&gt; will need to have a working C compiler toolchain on their machine for the command to work.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cibuildwheel&lt;/code&gt; solves the problem of building all of those wheels for all of those different platforms on the CI provider of your choice. Adam is using it in GitHub Actions for &lt;code&gt;time-machine&lt;/code&gt;, and his &lt;a href="https://github.com/adamchainz/time-machine/blob/2.15.0/.github/workflows/build.yml"&gt;.github/workflows/build.yml&lt;/a&gt; file neatly demonstrates how concise the configuration can be once you figure out how to use it.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://www.python.org/downloads/release/python-3130rc1/"&gt;first release candidate of Python 3.13&lt;/a&gt; hit its target release date of August 1st, and the final version looks on schedule for release on the 1st of October. Since this rc should be binary compatible with the final build now is the time to start shipping those wheels to PyPI.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/adamchainz/status/1820821199850476004"&gt;@adamchainz&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/packaging"&gt;packaging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/adam-johnson"&gt;adam-johnson&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="packaging"/><category term="pypi"/><category term="python"/><category term="adam-johnson"/></entry><entry><title>Quoting jbreckmckye</title><link href="https://simonwillison.net/2023/Jul/10/jbreckmckye/#atom-tag" rel="alternate"/><published>2023-07-10T18:53:41+00:00</published><updated>2023-07-10T18:53:41+00:00</updated><id>https://simonwillison.net/2023/Jul/10/jbreckmckye/#atom-tag</id><summary type="html">
    &lt;blockquote cite="https://news.ycombinator.com/item?id=36667469#36669622"&gt;&lt;p&gt;At The Guardian we had a pretty direct way to fix this [the problem of zombie feature flags]: experiments were associated with expiry dates, and if your team's experiments expired the build system simply wouldn't process your jobs without outside intervention. Seems harsh, but I've found with many orgs the only way to fix negative externalities in a shared codebase is a tool that says "you broke your promises, now we break your builds".&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://news.ycombinator.com/item?id=36667469#36669622"&gt;jbreckmckye&lt;/a&gt;&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/feature-flags"&gt;feature-flags&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="feature-flags"/></entry><entry><title>A tiny CI system</title><link href="https://simonwillison.net/2022/Apr/26/a-tiny-ci-system/#atom-tag" rel="alternate"/><published>2022-04-26T15:39:27+00:00</published><updated>2022-04-26T15:39:27+00:00</updated><id>https://simonwillison.net/2022/Apr/26/a-tiny-ci-system/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.0chris.com/tiny-ci-system.html"&gt;A tiny CI system&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Christian Ştefănescu shares a recipe for building a tiny self-hosted CI system using Git and Redis. A post-receive hook runs when a commit is pushed to the repo and uses redis-cli to push jobs to a list. Then a separate bash script runs a loop with a blocking “redis-cli blpop jobs” operation which waits for new jobs and then executes the CI job as a shell script.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/stchris_/status/1518977088723861505"&gt;@stchris_&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/bash"&gt;bash&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git"&gt;git&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/redis"&gt;redis&lt;/a&gt;&lt;/p&gt;



</summary><category term="bash"/><category term="continuous-integration"/><category term="git"/><category term="redis"/></entry><entry><title>Pillar Point Stewards, pypi-to-sqlite, improvements to shot-scraper and appreciating datasette-dashboards</title><link href="https://simonwillison.net/2022/Apr/8/weeknotes/#atom-tag" rel="alternate"/><published>2022-04-08T20:26:36+00:00</published><updated>2022-04-08T20:26:36+00:00</updated><id>https://simonwillison.net/2022/Apr/8/weeknotes/#atom-tag</id><summary type="html">
    &lt;p&gt;This week I helped Natalie launch the Pillar Point Stewards website and built a new tool for loading PyPI package data into SQLite, in order to help promote the excellent datasette-dashboards plugin by Romain Clement.&lt;/p&gt;
&lt;h4 id="pillar-point-stewards"&gt;Pillar Point Stewards&lt;/h4&gt;
&lt;p&gt;I've been helping my wife Natalie Downe build the website for the &lt;a href="https://www.pillarpointstewards.com/"&gt;Pillar Point Stewards&lt;/a&gt; initative that she is organizing on behalf of the San Mateo MPA Collaborative and California Academy of Sciences.&lt;/p&gt;
&lt;p&gt;We live in El Granada, CA - home to the Pillar Point reef.&lt;/p&gt;
&lt;p&gt;The reef has always been mixed-use, with harvesting of sea life such as mussels and clams legal provided the harvesters have an inexpensive fishing license.&lt;/p&gt;
&lt;p&gt;Unfortunately, during the pandemic the number of people harvesting the reef raised by an order of magnitude - up to over a thousand people in just a single weekend. This had a major impact on the biodiversity of the reef, as described in &lt;a href="https://baynature.org/2021/01/12/packed-at-pillar-point/"&gt;Packed at Pillar Point by Anne Marshall-Chalmers for Bay Nature&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Pillar Point Stewards is an initiative to recruit volunteer stewards to go out on the reef during low tides, talking to people and trying to inspire curiosity and discourage unsustainable harvesting practices.&lt;/p&gt;
&lt;p&gt;A very small part of the project is the website to support it, which helps volunteers sign up for shifts at low tides.&lt;/p&gt;
&lt;p&gt;We re-used some of the work we had previously done &lt;a href="https://simonwillison.net/2020/Aug/21/weeknotes-rocky-beaches/"&gt;for Rocky Beaches&lt;/a&gt;, in particular the logic for working with tide times &lt;a href="https://tidesandcurrents.noaa.gov/web_services_info.html"&gt;from NOAA&lt;/a&gt; to decide when the shifts should be.&lt;/p&gt;
&lt;p&gt;Natalie designed the site and built the front-end. I implemented the Django backend and integrated with &lt;a href="https://auth0.com/"&gt;Auth0&lt;/a&gt; in order to avoid running our own signup and registration flow. This was the inspiration for the &lt;a href="https://datasette.io/plugins/datasette-auth0"&gt;datasette-auth0&lt;/a&gt; plugin I &lt;a href="https://simonwillison.net/2022/Mar/28/datasette-auth0/"&gt;released last week&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Signed in volunteers can select their shift times from a calendar:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/www-pillarpointstewards-com.jpg" alt="The signed in homepage, showing a list of upcoming shifts and a calendar view." style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;We also included an SVG tide chart on each shift page using the tide data from NOAA, which looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/www-pillarpointstewards-com-shifts-182.png" alt="The tide chart shows the tide level throughout the day, highlighting the low tide and showing which portion of the day is covered by the shift" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;We've been building the site in public. You can see how everything works in the &lt;a href="https://github.com/natbat/pillarpointstewards"&gt;natbat/pillarpointstewards&lt;/a&gt; GitHub repository, including how the site uses &lt;a href="https://github.com/natbat/pillarpointstewards/blob/165cdcfe8b87cb15742e2729d0077202102fc751/.github/workflows/test.yml#L39-L45"&gt;continuous deployment&lt;/a&gt; against &lt;a href="https://fly.io/"&gt;Fly&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="datasette-dashboards"&gt;datasette-dashboards&lt;/h4&gt;
&lt;p&gt;This is not my project, but I'm writing about it here because I only just found out about it and it's really cool.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://romain-clement.net/"&gt;Romain Clement&lt;/a&gt; built a plugin for Datasette called &lt;a href="https://github.com/rclement/datasette-dashboards"&gt;datasette-dashboards&lt;/a&gt;. It's best explained by checking out his &lt;a href="https://datasette-dashboards-demo.vercel.app/-/dashboards/job-offers-stats"&gt;live demo&lt;/a&gt;, which looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2022/datasette-dashboards-with-filters.png" alt="A dashboard, showing Job offers statistics - with a line chart, a big number, a donut chart, a nested bar chart and a choropleth map. The elements are arranged in a visually pleasing grid, with the line chart taking up two columns while everything else takes up one." style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;There are a bunch of clever ideas in this plugin.&lt;/p&gt;
&lt;p&gt;It uses YAML syntax to define the different dashboard panels, outsourcing the actual visualization elements to Vega. You can see &lt;a href="https://github.com/rclement/datasette-dashboards/blob/202fb2fcbc9efe4848fe940fae435eff75bb4f59/demo/metadata.yml"&gt;the YAML for the demo here&lt;/a&gt;. Here's an edited subset of the YAML illustrating some interesting points:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;plugins&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;datasette-dashboards&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;job-offers-stats&lt;/span&gt;:
      &lt;span class="pl-ent"&gt;title&lt;/span&gt;: &lt;span class="pl-s"&gt;Job offers statistics&lt;/span&gt;
      &lt;span class="pl-ent"&gt;description&lt;/span&gt;: &lt;span class="pl-s"&gt;Gather metrics about job offers&lt;/span&gt;
      &lt;span class="pl-ent"&gt;layout&lt;/span&gt;:
        - &lt;span class="pl-s"&gt;[analysis-note, offers-day, offers-day, offers-count]&lt;/span&gt;
        - &lt;span class="pl-s"&gt;[analysis-note, offers-source, offers-day-source, offers-region]&lt;/span&gt;
      &lt;span class="pl-ent"&gt;filters&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;date_start&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Date Start&lt;/span&gt;
          &lt;span class="pl-ent"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;date&lt;/span&gt;
          &lt;span class="pl-ent"&gt;default&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2021-01-01&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
        &lt;span class="pl-ent"&gt;date_end&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Date End&lt;/span&gt;
          &lt;span class="pl-ent"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;date&lt;/span&gt;
      &lt;span class="pl-ent"&gt;charts&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;analysis-note&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;library&lt;/span&gt;: &lt;span class="pl-s"&gt;markdown&lt;/span&gt;
          &lt;span class="pl-ent"&gt;display&lt;/span&gt;: &lt;span class="pl-s"&gt;|-&lt;/span&gt;
&lt;span class="pl-s"&gt;            # Analysis details&lt;/span&gt;
&lt;span class="pl-s"&gt;            ...&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;        &lt;span class="pl-ent"&gt;offers-count&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;title&lt;/span&gt;: &lt;span class="pl-s"&gt;Total number of offers&lt;/span&gt;
          &lt;span class="pl-ent"&gt;db&lt;/span&gt;: &lt;span class="pl-s"&gt;jobs&lt;/span&gt;
          &lt;span class="pl-ent"&gt;query&lt;/span&gt;: &lt;span class="pl-s"&gt;SELECT count(*) as count FROM offers_view WHERE TRUE [[ AND date &amp;gt;= date(:date_start) ]] [[ AND date &amp;lt;= date(:date_end) ]];&lt;/span&gt;
          &lt;span class="pl-ent"&gt;library&lt;/span&gt;: &lt;span class="pl-s"&gt;metric&lt;/span&gt;
          &lt;span class="pl-ent"&gt;display&lt;/span&gt;:
            &lt;span class="pl-ent"&gt;field&lt;/span&gt;: &lt;span class="pl-s"&gt;count&lt;/span&gt;
            &lt;span class="pl-ent"&gt;prefix&lt;/span&gt;:
            &lt;span class="pl-ent"&gt;suffix&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt; offers&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
        &lt;span class="pl-ent"&gt;offers-day&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;title&lt;/span&gt;: &lt;span class="pl-s"&gt;Number of offers by day&lt;/span&gt;
          &lt;span class="pl-ent"&gt;db&lt;/span&gt;: &lt;span class="pl-s"&gt;jobs&lt;/span&gt;
          &lt;span class="pl-ent"&gt;query&lt;/span&gt;: &lt;span class="pl-s"&gt;SELECT date(date) as day, count(*) as count FROM offers_view WHERE TRUE [[ AND date &amp;gt;= date(:date_start) ]] [[ AND date &amp;lt;= date(:date_end) ]] GROUP BY day ORDER BY day&lt;/span&gt;
          &lt;span class="pl-ent"&gt;library&lt;/span&gt;: &lt;span class="pl-s"&gt;vega&lt;/span&gt;
          &lt;span class="pl-ent"&gt;display&lt;/span&gt;:
            &lt;span class="pl-ent"&gt;mark&lt;/span&gt;: &lt;span class="pl-s"&gt;{ type: line, tooltip: true }&lt;/span&gt;
            &lt;span class="pl-ent"&gt;encoding&lt;/span&gt;:
              &lt;span class="pl-ent"&gt;x&lt;/span&gt;: &lt;span class="pl-s"&gt;{ field: day, type: temporal }&lt;/span&gt;
              &lt;span class="pl-ent"&gt;y&lt;/span&gt;: &lt;span class="pl-s"&gt;{ field: count, type: quantitative }&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The SQL query for each panel is defined as &lt;code&gt;query:&lt;/code&gt; - and can take parameters such as &lt;code&gt;:date_end&lt;/code&gt; which are defined by the &lt;code&gt;filters:&lt;/code&gt; section. Note that here one of the filters has a type of &lt;code&gt;date&lt;/code&gt;, which turns into a &lt;code&gt;&amp;lt;input type="date"&amp;gt;&lt;/code&gt; in the filter interface.&lt;/p&gt;
&lt;p&gt;For &lt;code&gt;library: vega&lt;/code&gt; panels the &lt;code&gt;display:&lt;/code&gt; key holds the raw &lt;a href="https://vega.github.io/vega/docs/specification/"&gt;Vega specification&lt;/a&gt;, so anything the Vega visualization library can do is available to the plugin.&lt;/p&gt;
&lt;p&gt;I didn't know Vega could render choropleth maps! That map there is defined by this YAML, which loads a GeoJSON file of the regions in France from the &lt;a href="https://github.com/gregoiredavid/france-geojson"&gt;gregoiredavid/france-geojson&lt;/a&gt; GitHub repository.&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;display&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;mark&lt;/span&gt;: &lt;span class="pl-s"&gt;geoshape&lt;/span&gt;
  &lt;span class="pl-ent"&gt;projection&lt;/span&gt;: &lt;span class="pl-s"&gt;{ type: mercator }&lt;/span&gt;
  &lt;span class="pl-ent"&gt;transform&lt;/span&gt;:
    - &lt;span class="pl-ent"&gt;lookup&lt;/span&gt;: &lt;span class="pl-s"&gt;region&lt;/span&gt;
      &lt;span class="pl-ent"&gt;from&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;data&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/regions.geojson&lt;/span&gt;
          &lt;span class="pl-ent"&gt;format&lt;/span&gt;: &lt;span class="pl-s"&gt;{ type: json, property: features }&lt;/span&gt;
        &lt;span class="pl-ent"&gt;key&lt;/span&gt;: &lt;span class="pl-s"&gt;properties.nom&lt;/span&gt;
        &lt;span class="pl-ent"&gt;fields&lt;/span&gt;: &lt;span class="pl-s"&gt;[type, geometry]&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I think my favourite trick though is the way it handles layout. The layout for the demo is defined thus:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;layout&lt;/span&gt;:
  - &lt;span class="pl-s"&gt;[analysis-note, offers-day, offers-day, offers-count]&lt;/span&gt;
  - &lt;span class="pl-s"&gt;[analysis-note, offers-source, offers-day-source, offers-region]&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is then implemented &lt;a href="https://github.com/rclement/datasette-dashboards/blob/202fb2fcbc9efe4848fe940fae435eff75bb4f59/datasette_dashboards/templates/dashboard_view.html#L8-L26"&gt;using CSS grids&lt;/a&gt;! Here's the template fragment that does the work:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-django"&gt;&lt;pre&gt;&amp;lt;&lt;span class="pl-ent"&gt;style&lt;/span&gt;&amp;gt;&lt;span class="pl-s1"&gt;&lt;/span&gt;
&lt;span class="pl-s1"&gt;  &lt;span class="pl-k"&gt;@media&lt;/span&gt; (&lt;span class="pl-c1"&gt;min-width&lt;/span&gt;: &lt;span class="pl-c1"&gt;800&lt;span class="pl-k"&gt;px&lt;/span&gt;&lt;/span&gt;) {&lt;/span&gt;
&lt;span class="pl-s1"&gt;    &lt;span class="pl-e"&gt;.dashboard-grid&lt;/span&gt; {&lt;/span&gt;
&lt;span class="pl-s1"&gt;      {% &lt;span class="pl-c1"&gt;if&lt;/span&gt; &lt;span class="pl-c1"&gt;dashboard&lt;/span&gt;.&lt;span class="pl-c1"&gt;layout&lt;/span&gt; %}&lt;/span&gt;
&lt;span class="pl-s1"&gt;      &lt;span class="pl-ent"&gt;grid-template-areas&lt;/span&gt;: {% &lt;span class="pl-c1"&gt;for&lt;/span&gt; &lt;span class="pl-c1"&gt;row&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-c1"&gt;dashboard&lt;/span&gt;.&lt;span class="pl-c1"&gt;layout&lt;/span&gt; %}"{% &lt;span class="pl-c1"&gt;for&lt;/span&gt; &lt;span class="pl-c1"&gt;col&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-c1"&gt;row&lt;/span&gt; %}{{ &lt;span class="pl-c1"&gt;col&lt;/span&gt; }} {% &lt;span class="pl-c1"&gt;endfor&lt;/span&gt; %}" {% &lt;span class="pl-c1"&gt;endfor&lt;/span&gt; %};&lt;/span&gt;
&lt;span class="pl-s1"&gt;      {% &lt;span class="pl-c1"&gt;else&lt;/span&gt; %}&lt;/span&gt;
&lt;span class="pl-s1"&gt;      &lt;span class="pl-ent"&gt;grid-template-columns&lt;/span&gt;: repeat(2, 1fr);&lt;/span&gt;
&lt;span class="pl-s1"&gt;      {% &lt;span class="pl-c1"&gt;endif&lt;/span&gt; %}&lt;/span&gt;
&lt;span class="pl-s1"&gt;    }&lt;/span&gt;
&lt;span class="pl-s1"&gt;&lt;/span&gt;
&lt;span class="pl-s1"&gt;    {% &lt;span class="pl-c1"&gt;if&lt;/span&gt; &lt;span class="pl-c1"&gt;dashboard&lt;/span&gt;.&lt;span class="pl-c1"&gt;layout&lt;/span&gt; %}&lt;/span&gt;
&lt;span class="pl-s1"&gt;    {% &lt;span class="pl-c1"&gt;for&lt;/span&gt; &lt;span class="pl-c1"&gt;chart&lt;/span&gt;_&lt;span class="pl-c1"&gt;slug&lt;/span&gt;, &lt;span class="pl-c1"&gt;chart&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-c1"&gt;dashboard&lt;/span&gt;.&lt;span class="pl-c1"&gt;charts&lt;/span&gt;.&lt;span class="pl-c1"&gt;items&lt;/span&gt;() %}&lt;/span&gt;
&lt;span class="pl-s1"&gt;    &lt;span class="pl-e"&gt;#card-&lt;/span&gt;{{ &lt;span class="pl-c1"&gt;chart&lt;/span&gt;_&lt;span class="pl-c1"&gt;slug&lt;/span&gt; }} {&lt;/span&gt;
&lt;span class="pl-s1"&gt;      &lt;span class="pl-c1"&gt;&lt;span class="pl-c1"&gt;grid-area&lt;/span&gt;&lt;/span&gt;: {{ chart_slug }};&lt;/span&gt;
&lt;span class="pl-s1"&gt;    }&lt;/span&gt;
&lt;span class="pl-s1"&gt;    {% &lt;span class="pl-c1"&gt;endfor&lt;/span&gt; %}&lt;/span&gt;
&lt;span class="pl-s1"&gt;    {% &lt;span class="pl-c1"&gt;endif&lt;/span&gt; %}&lt;/span&gt;
&lt;span class="pl-s1"&gt;  }&lt;/span&gt;
&lt;span class="pl-s1"&gt;&lt;/span&gt;&amp;lt;/&lt;span class="pl-ent"&gt;style&lt;/span&gt;&amp;gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Such a clever and elegant trick.&lt;/p&gt;
&lt;h4 id="pypi-to-sqlite"&gt;pypi-to-sqlite&lt;/h4&gt;
&lt;p&gt;I wanted to add &lt;code&gt;datasette-dashboards&lt;/code&gt; to the official Datasette &lt;a href="https://datasette.io/plugins"&gt;plugins directory&lt;/a&gt;, but there was a catch: since most of the plugins listed there are written by me, the site has some baked in expectations: in particular, it expects that plugins will all be using the GitHub releases feature (&lt;a href="https://github.com/simonw/datasette-graphql/releases"&gt;for example&lt;/a&gt;) to announce their releases.&lt;/p&gt;
&lt;p&gt;Romain's plugin wasn't using that feature, instead maintaining &lt;a href="https://github.com/rclement/datasette-dashboards/blob/master/CHANGELOG.md"&gt;its own changelog file&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I've been meaning to make the plugin directory more forgiving for a while. I decided to switch from using GitHub releases as the definitive source of release information to using releases published to &lt;a href="https://pypi.org/"&gt;PyPI (the Python package index)&lt;/a&gt; instead.&lt;/p&gt;
&lt;p&gt;PyPI offers a stable JSON API: &lt;a href="https://pypi.org/pypi/datasette-dashboards/json"&gt;https://pypi.org/pypi/datasette-dashboards/json&lt;/a&gt; - which includes information on the package and all of its releases.&lt;/p&gt;
&lt;p&gt;To reliably pull that information into &lt;a href="https://datasette.io"&gt;datasette.io&lt;/a&gt; I decided on a two-step process. First, I set up a &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraper&lt;/a&gt; to archive the data that I cared about into a new repository called &lt;a href="https://github.com/simonw/pypi-datasette-packages"&gt;pypi-datasette-packages&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That repo stores the current PyPI JSON for every package listed on the Datasette website. This means I can see changes made to those files over time by browsing the commit history. It also means that if PyPI is unavailable I can still &lt;a href="https://simonwillison.net/2020/Dec/13/datasette-io/"&gt;build and deploy&lt;/a&gt; the site.&lt;/p&gt;
&lt;p&gt;Then I wrote a new tool called &lt;a href="https://datasette.io/tools/pypi-to-sqlite"&gt;pypi-to-sqlite&lt;/a&gt; to load that data into SQLite database tables. You can try that out like so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install pypi-to-sqlite
pypi-to-sqlite pypi.db datasette-dashboards pypi-to-sqlite --prefix pypi_
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;--prefix&lt;/code&gt; option causes the tables to be created with the specified prefix in their names.&lt;/p&gt;
&lt;p&gt;Here are the three tables generated by that command:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datasette.io/content/pypi_packages"&gt;pypi_packages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datasette.io/content/pypi_versions"&gt;pypi_versions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datasette.io/content/pypi_releases"&gt;pypi_releases&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Using data from these tables I was able to &lt;a href="https://github.com/simonw/datasette.io/issues/98#issuecomment-1093144133"&gt;rework the SQL view&lt;/a&gt; that powers the plugins and tools directories on the site, and now &lt;a href="https://datasette.io/plugins/datasette-dashboards"&gt;datasette-dashboards has its own page there&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="shot-scraper-10-11"&gt;shot-scraper 0.10 and 0.11&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/shot-scraper"&gt;shot-scraper&lt;/a&gt; is my tool for taking automated screenshots of web pages, built on top of &lt;a href="https://playwright.dev/"&gt;Playwright&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://twitter.com/palewire"&gt;Ben Welsh&lt;/a&gt; has been a key early adopter of &lt;code&gt;shot-scraper&lt;/code&gt;, using it to power his &lt;a href="https://github.com/palewire/news-homepages"&gt;news-homepages&lt;/a&gt; project which takes screenshots of various news websites and then both &lt;a href="https://twitter.com/newshomepages"&gt;tweets the results&lt;/a&gt; and uploads them to the &lt;a href="https://archive.org/details/news-homepages"&gt;News Homepages collection&lt;/a&gt; on the Internet Archive.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/shot-scraper/releases/tag/0.10"&gt;shot-scraper 0.10&lt;/a&gt; is mostly Ben's work: he contributed both a &lt;code&gt;--timeout&lt;/code&gt; option and a &lt;code&gt;--browser&lt;/code&gt; option to let you install and use browsers other than the Chromium default!&lt;/p&gt;
&lt;p&gt;(Ben needed this because some news homepages were embedding videos in a format that &lt;a href="https://github.com/microsoft/playwright/issues/13093"&gt;wasn't supported by Chromium&lt;/a&gt; but did work fine in regular Chrome.)&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/ryancheley"&gt;Ryan Cheley&lt;/a&gt; also contributed to 0.10 - thanks to Ryan, the &lt;code&gt;shot-scraper multi&lt;/code&gt; command now continues taking shots even if one of them fails, unless you pass the &lt;code&gt;--fail-on-error&lt;/code&gt; flag.&lt;/p&gt;
&lt;p&gt;In writing my weeknotes, I decided to use &lt;code&gt;shot-scraper&lt;/code&gt; to take a screenshot of the signed in homepage of the &lt;a href="https://www.pillarpointstewards.com/"&gt;www.pillarpointstewards.com&lt;/a&gt; site.&lt;/p&gt;
&lt;p&gt;In doing so, I found out that Google SSO &lt;a href="https://github.com/simonw/shot-scraper/issues/61"&gt;refuses to work&lt;/a&gt; with the default Playwright Chromium! But it does continue to work with Firefox, so I fixed the &lt;code&gt;shot-scraper auth&lt;/code&gt; to support the &lt;code&gt;--browser&lt;/code&gt; option.&lt;/p&gt;
&lt;p&gt;I took the screenshot like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper auth https://www.pillarpointstewards.com/ -b firefox auth.json
# Now manually sign in with Auth0 and Google
shot-scraper https://www.pillarpointstewards.com/ -b firefox -a auth.json \
  --javascript "
    Array.from(
      document.querySelectorAll('[href^=tel]')
    ).forEach(el =&amp;gt; el.innerHTML = '(xxx) xxx-xxxx')"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;--javascript&lt;/code&gt; line there redacts the phone numbers that are displayed on the page to signed in volunteers.&lt;/p&gt;
&lt;p&gt;I created the second screenshot of just the tide times chart using this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper https://www.pillarpointstewards.com/shifts/182/ \
  -b firefox -a auth.json \
  --selector '.primary h2:nth-child(8)' \
  --selector .day-alone --padding 15
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/shot-scraper/releases/tag/0.11"&gt;shot-scraper 0.11&lt;/a&gt;, released a few minutes ago, contains the new &lt;code&gt;auth --browser&lt;/code&gt; feature plus some additional contributions from Ben Welsh, Ryan Murphy and Ian Wootten:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;shot-scraper accessibility --timeout&lt;/code&gt; option, thanks &lt;a href="https://github.com/palewire"&gt;Ben Welsh&lt;/a&gt;. &lt;a href="https://github.com/simonw/shot-scraper/pull/59"&gt;#59&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shot-scraper auth --browser&lt;/code&gt; option for authentication using a browser other than Chromium. &lt;a href="https://github.com/simonw/shot-scraper/issues/61"&gt;#61&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Using &lt;code&gt;--quality&lt;/code&gt; now results in a JPEG file with the correct &lt;code&gt;.jpg&lt;/code&gt; extension. Thanks, &lt;a href="https://github.com/iwootten"&gt;Ian Wootten&lt;/a&gt;. &lt;a href="https://github.com/simonw/shot-scraper/pull/58"&gt;#58&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;--reduced-motion&lt;/code&gt; flag for emulating the "prefers-reduced-motion" media feature. Thanks, &lt;a href="https://github.com/rdmurphy"&gt;Ryan Murphy&lt;/a&gt;. &lt;a href="https://github.com/simonw/shot-scraper/issues/49"&gt;#49&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/0.11"&gt;0.11&lt;/a&gt; - (&lt;a href="https://github.com/simonw/shot-scraper/releases"&gt;12 releases total&lt;/a&gt;) - 2022-04-08
&lt;br /&gt;Tools for taking automated screenshots of websites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/pypi-to-sqlite"&gt;pypi-to-sqlite&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/pypi-to-sqlite/releases/tag/0.2.2"&gt;0.2.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/pypi-to-sqlite/releases"&gt;3 releases total&lt;/a&gt;) - 2022-04-08
&lt;br /&gt;Load data about Python packages from PyPI into SQLite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/0.10"&gt;0.10&lt;/a&gt; - (&lt;a href="https://github.com/simonw/shot-scraper/releases"&gt;12 releases total&lt;/a&gt;) - 2022-03-29
&lt;br /&gt;Tools for taking automated screenshots of websites&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/webassembly/compile-to-wasm-llvm-macos"&gt;Compiling to WASM with llvm on macOS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/python/calendar-weeks"&gt;Generating a calendar week grid with the Python Calendar module&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/docker/docker-for-mac-container-to-postgresql-on-host"&gt;Allowing a container in Docker Desktop for Mac to talk to a PostgreSQL server on the host machine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/pytest/treat-warnings-as-errors"&gt;Treating warnings as errors in pytest&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/auth0/auth0-logout"&gt;Logging users out of Auth0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/bash/use-awk-to-add-a-prefix"&gt;Using awk to add a prefix&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/natalie-downe"&gt;natalie-downe&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ben-welsh"&gt;ben-welsh&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="natalie-downe"/><category term="plugins"/><category term="projects"/><category term="pypi"/><category term="datasette"/><category term="weeknotes"/><category term="shot-scraper"/><category term="ben-welsh"/></entry><entry><title>Running GitHub on Rails 6.0</title><link href="https://simonwillison.net/2021/Aug/6/running-github-on-rails-60/#atom-tag" rel="alternate"/><published>2021-08-06T16:30:59+00:00</published><updated>2021-08-06T16:30:59+00:00</updated><id>https://simonwillison.net/2021/Aug/6/running-github-on-rails-60/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.blog/2019-09-09-running-github-on-rails-6-0/"&gt;Running GitHub on Rails 6.0&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Back in 2019 Eileen M. Uchitelle explained how GitHub upgraded everything in production to Rails 6.0 within 1.5 weeks of the stable release. There’s a trick in here I really like: they have an automated weekly job which fetches the latest Rails main branch and runs the full GitHub test suite against it, giving them super-early warnings about anything that might break and letting them provide feedback to upstream about unintended regressions.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rails"&gt;rails&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="github"/><category term="rails"/></entry><entry><title>PAGNIs: Probably Are Gonna Need Its</title><link href="https://simonwillison.net/2021/Jul/1/pagnis/#atom-tag" rel="alternate"/><published>2021-07-01T19:13:58+00:00</published><updated>2021-07-01T19:13:58+00:00</updated><id>https://simonwillison.net/2021/Jul/1/pagnis/#atom-tag</id><summary type="html">
    &lt;p&gt;Luke Page has a great post up with &lt;a href="https://lukeplant.me.uk/blog/posts/yagni-exceptions/"&gt;his list of YAGNI exceptions&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;YAGNI - You Ain't Gonna Need It - is a rule that says you shouldn't add a feature just because it might be useful in the future - only write code when it solves a direct problem.&lt;/p&gt;
&lt;p&gt;When should you over-ride YAGNI? When the cost of adding something later is so dramatically expensive compared with the cost of adding it early on that it's worth taking the risk. On when you know from experience that an initial investment will pay off many times over.&lt;/p&gt;
&lt;p&gt;Lukes's exceptions to YAGNI are well chosen: things like logging, API versioning, created_at timestamps and a bias towards "store multiple X for a user" (a many-to-many relationship) if there's any inkling that the system may need to support more than one.&lt;/p&gt;
&lt;p&gt;Because I like attempting to coin phrases, I propose we call these &lt;strong&gt;PAGNIs&lt;/strong&gt; - short for &lt;strong&gt;Probably Are Gonna Need Its&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here are some of mine.&lt;/p&gt;
&lt;h4&gt;A kill-switch for your mobile apps&lt;/h4&gt;
&lt;p&gt;If you're building a mobile app that talks to your API, make sure to ship a kill-switch: a mechanism by which you can cause older versions of the application to show a "you must upgrade to continue using this application" screen when the app starts up.&lt;/p&gt;
&lt;p&gt;In an ideal world, you'll never use this ability: you'll continue to build new features to the app and make backwards-compatible changes to the API forever, such that ancient app versions keep working and new app versions get to do new things.&lt;/p&gt;
&lt;p&gt;But... sometimes that simply isn't possible. You might discover a security hole in the design of the application or API that can only be fixed by breaking backwards-compatibility - or maybe you're still maintaining a v1 API from five years ago to support a mobile application version that's only still installed by 30 users, and you'd like to not have to maintain double the amount of API code.&lt;/p&gt;
&lt;p&gt;You can't add a kill-switch retroactively to apps that have already been deployed!&lt;/p&gt;
&lt;p&gt;&lt;a href="https://twitter.com/myunderpants/status/1410655652867809281"&gt;Apparently Firebase offers this&lt;/a&gt; to many Android apps, but if you're writing for iOS you need to provide this yourself.&lt;/p&gt;
&lt;h4&gt;Automated deploys&lt;/h4&gt;
&lt;p&gt;Nothing kills a side project like coming back to it in six months time and having to figure out how to deploy it again. Thanks to &lt;a href="https://simonwillison.net/tags/githubactions/"&gt;GitHub Actions&lt;/a&gt; and hosting providers like Google Cloud Run, Vercel, Heroku and Netlify setting up automated deployments is way easier now than it used to be. I have enough examples now that getting automated deployments working for a new project usually only takes a few minutes, and it pays off instantly.&lt;/p&gt;
&lt;h4&gt;Continuous Integration (and a test framework)&lt;/h4&gt;
&lt;p&gt;Similar to automated deployment in that GitHub Actions (and Circle CI and Travis before it) make this much less painful to setup than it used to be.&lt;/p&gt;
&lt;p&gt;Introducing a test framework to an existing project can be extremely painful. Introducing it at the very start is easy - and it sets a precedent that code should be tested from day one.&lt;/p&gt;
&lt;p&gt;These days I'm all about &lt;a href="https://simonwillison.net/tags/pytest/"&gt;pytest&lt;/a&gt;, and I have various cookiecutter templates (&lt;a href="https://github.com/simonw/datasette-plugin"&gt;datasette-plugin&lt;/a&gt;, &lt;a href="https://github.com/simonw/click-app"&gt;click-app&lt;/a&gt;, &lt;a href="https://github.com/simonw/python-lib"&gt;python-lib&lt;/a&gt;) that configure it on my new projects (with a passing test) out of the box.&lt;/p&gt;
&lt;p&gt;(Honestly, at this point in my career I consider continuous integration a DAGNI - Definitely Are Gonna Need It.)&lt;/p&gt;
&lt;p&gt;One particularly worthwhile trick is making sure the tests can spin up their own isolated test databases - another thing which is pretty easy to setup early (Django does this for you) and harder to add later on. I extend that to other external data stores - I once put a significant amount of effort into setting up a mechanism for running tests against Elasticsearch and clearing out the data again afterwards, and it paid off multiple times over.&lt;/p&gt;
&lt;p&gt;Even better: &lt;strong&gt;continuous deployment&lt;/strong&gt;! When the tests pass, deploy. If you have automated deployment setup already adding this is pretty easy, and doing it from the very start of a project sets a strong cultural expectation that no-one will land code to the &lt;code&gt;main&lt;/code&gt; branch until it's in a production-ready state and covered by unit tests.&lt;/p&gt;
&lt;p&gt;(If continuous deployment to production is too scary for your project, a valuable middle-ground is continuous deployment to a staging environment. Having everyone on your team able to interact with a live demo of your current main branch is a huge group productivity boost.)&lt;/p&gt;
&lt;h4&gt;API pagination&lt;/h4&gt;
&lt;p&gt;Never build an API endpoint that isn't paginated. Any time you think "there will never be enough items in this list for it to be worth pagination" one of your users will prove you wrong.&lt;/p&gt;
&lt;p&gt;This can be as simple as shipping an API which, even though it only returns a single page, has hard-coded JSON that looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;results&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: [
    {&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;One&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;},
    {&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Two&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;},
    {&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;3&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Three&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;}
  ],
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;next_url&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;But make sure you leave space for the pagination information! You'll regret it if you don't.&lt;/p&gt;
&lt;h4&gt;Detailed API logs&lt;/h4&gt;
&lt;p&gt;This is a trick I learned &lt;a href="https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/#value-of-api-logs"&gt;while porting VaccinateCA to Django&lt;/a&gt;. If you are building an API, having a mechanism that provides detailed logs - including the POST bodies passed to the API - is invaluable.&lt;/p&gt;
&lt;p&gt;It's an inexpensive way of maintaining a complete record of what happened with your application - invaluable for debugging, but also for tricks like replaying past API traffic against a new implementation under test.&lt;/p&gt;
&lt;p&gt;Logs like these may become infeasible at scale, but for a new project they'll probably add up to just a few MBs a day - and they're easy to prune or switch off later on if you need to.&lt;/p&gt;
&lt;p&gt;VIAL uses &lt;a href="https://github.com/CAVaccineInventory/vial/blob/a0780e27c39018b66f95278ce18eda5968c325f8/vaccinate/api/utils.py#L86"&gt;a Django view decorator&lt;/a&gt; to log these directly to a PostgreSQL table. We've been running this for a few months and it's now our largest table, but it's still only around 2GB - easily worth it for the productivity boost it gives us.&lt;/p&gt;
&lt;p&gt;(Don't log any sensitive data that you wouldn't want your development team having access to while debugging a problem. This may require clever redaction, or you can avoid logging specific endpoints entirely. Also: don't log authentication tokens that could be used to imitate users: decode them and log the user identifier instead.)&lt;/p&gt;
&lt;h4&gt;A bookmarkable interface for executing read-only SQL queries against your database&lt;/h4&gt;
&lt;p&gt;This one is very much exposing my biases (I just released &lt;a href="https://django-sql-dashboard.datasette.io/"&gt;Django SQL Dashboard 1.0&lt;/a&gt; which provides exactly this for Django+PosgreSQL projects) but having used this for the past few months I can't see myself going back. Using bookmarked SQL queries to inform the implementation of new features is an incredible productivity boost. Here's &lt;a href="https://github.com/CAVaccineInventory/vial/issues/528"&gt;an issue I worked on&lt;/a&gt; recently with 18 comments linking to illustrative SQL queries.&lt;/p&gt;
&lt;p&gt;(On further thought: this isn't actually a great example of a PAGNI because it's not particularly hard to add this to a project at a later date.)&lt;/p&gt;
&lt;h4&gt;Driving down the cost&lt;/h4&gt;
&lt;p&gt;One trick with all of these things is that while they may seem quite expensive to implement, they get dramatically cheaper as you gain experience and gather more tools for helping put them into practice.&lt;/p&gt;
&lt;p&gt;Any of the ideas I've shown here could take an engineering team weeks (if not months) to add to an existing project - but with the right tooling they can represent just an hour (or less) work at the start of a project. And they'll pay themselves off many, many times over in the future.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/definitions"&gt;definitions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/software-engineering"&gt;software-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pytest"&gt;pytest&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yagni"&gt;yagni&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pagni"&gt;pagni&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="definitions"/><category term="software-engineering"/><category term="testing"/><category term="pytest"/><category term="github-actions"/><category term="django-sql-dashboard"/><category term="yagni"/><category term="pagni"/></entry><entry><title>Quoting Drew DeVault</title><link href="https://simonwillison.net/2021/Apr/26/drew-devault/#atom-tag" rel="alternate"/><published>2021-04-26T23:52:32+00:00</published><updated>2021-04-26T23:52:32+00:00</updated><id>https://simonwillison.net/2021/Apr/26/drew-devault/#atom-tag</id><summary type="html">
    &lt;blockquote cite="https://drewdevault.com/2021/04/26/Cryptocurrency-is-a-disaster.html"&gt;&lt;p&gt;Over the past several months, everyone in the industry who provides any kind of free CPU resources has been dealing with a massive outbreak of abuse for cryptocurrency mining. The industry has been setting up informal working groups to pool knowledge of mitigations, communicate when our platforms are being leveraged against one another, and cumulatively wasting thousands of hours of engineering time implementing measures to deal with this abuse, and responding as attackers find new ways to circumvent them.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://drewdevault.com/2021/04/26/Cryptocurrency-is-a-disaster.html"&gt;Drew DeVault&lt;/a&gt;, SourceHut&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/bitcoin"&gt;bitcoin&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="security"/><category term="bitcoin"/></entry><entry><title>Spinning up a new Django app to act as a backend for VaccinateCA</title><link href="https://simonwillison.net/2021/Feb/23/vaccinateca-2021-02-23/#atom-tag" rel="alternate"/><published>2021-02-23T17:00:00+00:00</published><updated>2021-02-23T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Feb/23/vaccinateca-2021-02-23/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;My goal by the end of this week is to have a working proof of concept for a Django + PostgreSQL app that can replace Airtable as the principle backend for the &lt;a href="https://www.vaccinateca.com/" rel="nofollow"&gt;https://www.vaccinateca.com/&lt;/a&gt; site. This proof of concept will allow us to make a go or no-go decision and figure out what else needs to be implemented before we can start using it to track calls.&lt;/p&gt;
&lt;p&gt;I'm calling it a "prototype" and a "proof of concept", but my career has taught me that prototypes often end up going into production - so I'm building it with that in mind.&lt;/p&gt;
&lt;p&gt;Today I started building that app. The repo is currently &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate"&gt;https://github.com/CAVaccineInventory/django.vaccinate&lt;/a&gt; though we are likely to rename it soon - possibly to VIAL (for Vaccine Information Archive and Library) - Jesse is good at actually relevant codenames!&lt;/p&gt;
&lt;p&gt;Here's what I have so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Proof of concept for Auth0 SSO - mostly from following &lt;a href="https://auth0.com/docs/quickstart/webapp/django/01-login" rel="nofollow"&gt;their tutorial&lt;/a&gt;. You can try that out on the staging site homepage at &lt;a href="https://vaccinateca-preview.herokuapp.com/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The very beginnings of a test suite - built using &lt;code&gt;pytest-django&lt;/code&gt;. The tests run against PostgreSQL and I had to figure out how to do that inside GitHub Actions - here's &lt;a href="https://til.simonwillison.net/github-actions/postgresq-service-container" rel="nofollow"&gt;my TIL&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The tests run in GitHub Actions! Continous Integration - should work against pull requests too.&lt;/li&gt;
&lt;li&gt;... which means we can take the next step and go for Continuous Deployment. Every commit that passes the tests is now deployed instantly to the staging environment.&lt;/li&gt;
&lt;li&gt;The staging environment itself is currently on Heroku, because they make it ridiculously easy to setup Continuous Deployment - it's literally a checkbox in their admin panel. I wrote about this a few years ago: &lt;a href="https://simonwillison.net/2017/Oct/17/free-continuous-deployment/" rel="nofollow"&gt;How to set up world-class continuous deployment using free hosted tools&lt;/a&gt;. It's likely we'll move this to Google Cloud at some point since other VaccinateCA stuff is running there. I know how to run Continous Deployment using Google Cloud Run so that could be a good option here.&lt;/li&gt;
&lt;li&gt;Errors now get logged to &lt;a href="https://sentry.io/organizations/vaccinateca/issues/?project=5649843" rel="nofollow"&gt;a new project&lt;/a&gt; in the VaccinateCA Sentry instance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the biggest thing: I've implemented &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/vaccinate/core/models.py"&gt;a set of Django models&lt;/a&gt; against the SQL schema that has been coming together in &lt;a href="https://github.com/CAVaccineInventory/data-engineering/pull/2"&gt;this pull request&lt;/a&gt;. These are exposed in the Django Admin (just with default settings, no customization yet) in the staging environment.&lt;/p&gt;
&lt;p&gt;You can try those out by visiting &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/&lt;/a&gt; and signing in with username &lt;code&gt;demo&lt;/code&gt; and password &lt;code&gt;demo&lt;/code&gt; (this account will be deleted the second we have any real data in the prototype).&lt;/p&gt;
&lt;p&gt;I wrote data migrations to &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/vaccinate/core/migrations/0002_populate_states.py"&gt;insert states&lt;/a&gt; and &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/vaccinate/core/migrations/0003_populate_ca_counties.py"&gt;insert counties&lt;/a&gt; - you can see the results in the admin &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/county/" rel="nofollow"&gt;here (counties)&lt;/a&gt; and &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/state/" rel="nofollow"&gt;here (states)&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;
Some engineering principles&lt;/h4&gt;
&lt;p&gt;I've invested a lot of effort today in getting some fundamental things set up: a test suite, continuous integration, continuous deployment, and a detailed and up-to-date &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/README.md"&gt;README&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The reason I'm investing that effort now is that I know from experience that these things are incredibly valuable, and very easy to implement at the start of a project... but become massively harder the longer you delay them. Adding comprehensive tests, documentation and CI to a six month old project can take weeks. Adding them to a project that is just starting takes just a few hours.&lt;/p&gt;
&lt;p&gt;I also plan to lean &lt;strong&gt;very&lt;/strong&gt; heavily on the Django migrations system.&lt;/p&gt;
&lt;p&gt;I've worked at companies in the past where database migrations - any kind of schema change - are slow, rare and exciting. This has horrible knock-on effects: engineers will go to great lengths to avoid adding a column to a table, which can lead to a rapid acretion of technical debt.&lt;/p&gt;
&lt;p&gt;I want schema changes to be quick, common and boring. Django's migration system - especially against PostgreSQL, which can execute schema changes inside transactions - is ideally suited to this. I want to start using it agressively as early as possible, to ensure we have a culture that says "yes" to schema changes and executes them promptly and frequently.&lt;/p&gt;
&lt;h4&gt;
Next steps&lt;/h4&gt;
&lt;p&gt;I want to get some real data into the system! I'm going to lock down the security a bit more, then take some exports from Airtable, convert them to the new schema and load them into the prototype. This will allow us to really start kicking the tires on it.&lt;/p&gt;
&lt;p&gt;I'm tracking all of the work on the Django app in &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues"&gt;the issues&lt;/a&gt; for that repository.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="django"/><category term="django-admin"/><category term="postgresql"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Quoting Vincent Driessen</title><link href="https://simonwillison.net/2020/May/14/vincent-driessen/#atom-tag" rel="alternate"/><published>2020-05-14T13:49:55+00:00</published><updated>2020-05-14T13:49:55+00:00</updated><id>https://simonwillison.net/2020/May/14/vincent-driessen/#atom-tag</id><summary type="html">
    &lt;blockquote cite="https://nvie.com/posts/a-successful-git-branching-model/"&gt;&lt;p&gt;Web apps are typically continuously delivered, not rolled back, and you don't have to support multiple versions of the software running in the wild.&lt;/p&gt;
&lt;p&gt;This is not the class of software that I had in mind when I wrote the blog post 10 years ago. If your team is doing continuous delivery of software, I would suggest to adopt a much simpler workflow (like GitHub flow) instead of trying to shoehorn git-flow into your team.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://nvie.com/posts/a-successful-git-branching-model/"&gt;Vincent Driessen&lt;/a&gt;&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git"&gt;git&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="git"/></entry><entry><title>Tracking FARA by deploying a data API using GitHub Actions and Cloud Run</title><link href="https://simonwillison.net/2020/Jan/21/github-actions-cloud-run/#atom-tag" rel="alternate"/><published>2020-01-21T07:51:11+00:00</published><updated>2020-01-21T07:51:11+00:00</updated><id>https://simonwillison.net/2020/Jan/21/github-actions-cloud-run/#atom-tag</id><summary type="html">
    &lt;p&gt;I'm using the combination of GitHub Actions and Google Cloud Run to retrieve data from the U.S. Department of Justice FARA website and deploy it as a queryable API using Datasette.&lt;/p&gt;

&lt;h3&gt;FARA background&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://www.justice.gov/nsd-fara"&gt;Foreign Agents Registration Act (FARA)&lt;/a&gt; law that requires "certain agents of foreign principals who are engaged in political activities or other activities specified under the statute to make periodic public disclosure of their relationship with the foreign principal, as well as activities, receipts and disbursements in support of those activities".&lt;/p&gt;

&lt;p&gt;The law was introduced in 1938 in response to the large number of German propaganda agents that were operating in the U.S. prior to the war.&lt;/p&gt;

&lt;p&gt;Basically, if you are in the United States as a lobbyist for a foreign government you need to register under FARA. It was used in 23 criminal cases during World War II, but hasn't had much use since it was ammended in 1966. Although... if you consult the &lt;a href="https://www.justice.gov/nsd-fara/recent-cases"&gt;list of recent cases&lt;/a&gt; you'll see some very interesting recent activity involving Russia and Ukraine.&lt;/p&gt;

&lt;p&gt;It's also for spies! Quoting &lt;a href="https://www.justice.gov/nsd-fara/general-fara-frequently-asked-questions"&gt;the FARA FAQ&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;&lt;p&gt;Finally, 50 U.S.C. § 851, requires registration of persons who have knowledge of or have received instruction or assignment in espionage, counterespionage or sabotage service or tactics of a foreign country or political party.&lt;/p&gt;&lt;/blockquote&gt;

&lt;p&gt;I imagine most spies operate in violation of this particular law and don't take steps to register themselves.&lt;/p&gt;

&lt;p&gt;It's all still pretty fascinating though, in part because it gets updated. A lot. Almost every business day in fact.&lt;/p&gt;

&lt;h3&gt;Tracking FARA history&lt;/h3&gt;

&lt;p&gt;I know this because seven months ago I set up a scraper for it. Every twelve hours I have code which downloads the &lt;a href="https://efile.fara.gov/ords/f?p=API:BULKDATA"&gt;four bulk CSVs&lt;/a&gt; published by the Justice department and saves them to &lt;a href="https://github.com/simonw/fara-history"&gt;a git repository&lt;/a&gt;. It's the same trick I've been using &lt;a href="https://simonwillison.net/2019/Mar/13/tree-history/"&gt;to track San Francisco's database of trees&lt;/a&gt; and &lt;a href="https://simonwillison.net/2019/Oct/10/pge-outages/"&gt;PG&amp;amp;E's outage map&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I've been running the scraper using Circle CI, but this weekend I decided to switch it over to &lt;a href="https://github.com/features/actions"&gt;GitHub Actions&lt;/a&gt; to get a better idea for how they work.&lt;/p&gt;

&lt;h3&gt;Deploying it as an API&lt;/h3&gt;

&lt;p&gt;I also wanted to upgrade my script to also deploy a fresh &lt;a href="https://datasette.readthedocs.io/"&gt;Datasette&lt;/a&gt; instance of the data using &lt;a href="https://cloud.google.com/run/"&gt;Google Cloud Run&lt;/a&gt;. I wrote &lt;a href="https://github.com/simonw/fara-datasette"&gt;a script&lt;/a&gt; to do this on a manual basis last year, but I never combined it with the daily scraper. Combining the two means I can offer a Datasette-powered API directly against the latest data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fara.datasettes.com/"&gt;https://fara.datasettes.com&lt;/a&gt; is that API - it now updates twice a day, assuming there are some changes to the underlying data.&lt;/p&gt;

&lt;h3&gt;Putting it all together&lt;/h3&gt;

&lt;p&gt;The final GitHub action workflow can be &lt;a href="https://github.com/simonw/fara-history/blob/7e33f2fc4619247e77d9b3b725ace6584228b601/.github/workflows/scheduled.yml"&gt;seen here&lt;/a&gt;. I'm going to present an annotated version here.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;on:
  repository_dispatch:
  schedule:
    - cron:  '0 0,12 * * *'&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This sets when the workflow should be triggered. I'm running it twice a day - at midnight and noon UTC (the 0,12 cron syntax).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;repository_dispatch&lt;/code&gt; key means I can also &lt;a href="https://help.github.com/en/actions/automating-your-workflow-with-github-actions/events-that-trigger-workflows#external-events-repository_dispatch"&gt;trigger it manually&lt;/a&gt; by running the following &lt;code&gt;curl&lt;/code&gt; command - useful for testing:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;curl -XPOST https://api.github.com/repos/simonw/fara-history/dispatches \
    -H 'Authorization: token MY_PERSONAL_TOKEN_HERE' \
    -d '{"event_type": "trigger_action"}' \
    -H 'Accept: application/vnd.github.everest-preview+json'&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next comes the job itself, which I called &lt;code&gt;scheduled&lt;/code&gt; and set to run on the latest Ubuntu:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;jobs:
  scheduled:
    runs-on: ubuntu-latest
    steps:&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next comes the steps. Each step is run in turn, in an isolated process (presumably a container) but with access to the current working directory.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- uses: actions/checkout@v2
  name: Check out repo
- name: Set up Python
  uses: actions/setup-python@v1
  with:
    python-version: 3.8&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The first two steps checkout the &lt;code&gt;fara-history&lt;/code&gt; repository and install Python 3.8.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- uses: actions/cache@v1
  name: Configure pip caching
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This step &lt;em&gt;should&lt;/em&gt; set up a cache so that &lt;code&gt;pip&lt;/code&gt; doesn't have to download fresh dependencies on every run. Unfortunately it doesn't seem to actually work - it only works for &lt;code&gt;push&lt;/code&gt; and &lt;code&gt;pull_request&lt;/code&gt; events, but my workflow is triggered by &lt;code&gt;schedule&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt;. There's &lt;a href="https://github.com/actions/cache/issues/63"&gt;an open issue about this&lt;/a&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Install Python dependencies
  run: |
    python -m pip install --upgrade pip
    pip install -r requirements.txt&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This step installs my dependencies &lt;a href="https://github.com/simonw/fara-history/blob/7e33f2fc4619247e77d9b3b725ace6584228b601/requirements.txt"&gt;from requirements.txt&lt;/a&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Fetch, update and commit FARA data
  run: . update_and_commit_all.sh
- name: Build fara.db database
  run: python build_database.py&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now we're getting to the fun stuff. My &lt;a href="https://github.com/simonw/fara-history/blob/7e33f2fc4619247e77d9b3b725ace6584228b601/update_and_commit_all.sh"&gt;update_and_commit_all.sh&lt;/a&gt; script downloads the four zip files &lt;a href="https://efile.fara.gov/ords/f?p=API:BULKDATA"&gt;from the FARA.gov site&lt;/a&gt;, unzips them, sorts them, diffs them against the previously stored files and commits the new copy to GitHub if they have changed. See &lt;a href="https://simonwillison.net/2019/Mar/13/tree-history/#csvdiff_18"&gt;my explanation of csv-diff&lt;/a&gt; for more on this - though sadly only one of the files has a reliable row identifier so I can't generate great commit messages for most of them.&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://github.com/simonw/fara-history/blob/7e33f2fc4619247e77d9b3b725ace6584228b601/build_database.py"&gt;build_database.py&lt;/a&gt; script uses &lt;a href="https://sqlite-utils.readthedocs.io/"&gt;sqlite-utils&lt;/a&gt; to convert the CSV files into a SQLite database.&lt;/p&gt;

&lt;p&gt;Now that we've got a SQLite database, we can &lt;a href="https://datasette.readthedocs.io/en/stable/publish.html#publishing-to-google-cloud-run"&gt;deploy it to Google Cloud Run&lt;/a&gt; using Datasette.&lt;/p&gt;

&lt;p&gt;But should we run a deploy at all? If the database hasn't changed, there's no point in deploying it. How can we tell if the database file has changed from the last one that was published?&lt;/p&gt;

&lt;p&gt;Datasette has a mechanism for deriving a content hash of a database, part of &lt;a href="https://datasette.readthedocs.io/en/stable/performance.html"&gt;a performance optimization&lt;/a&gt; which is no longer turned on by default and may be removed in the future.&lt;/p&gt;

&lt;p&gt;You can generate JSON that includes hash using the &lt;code&gt;datasette inspect&lt;/code&gt; command. The jq tool can then be used to extract out just the hash:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ datasette inspect fara.db | jq '.fara.hash' -r
fbc9cbaca6de1e232fc14494faa06cc8d4cb9f379d0d568e4711e9a218800906&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;-r&lt;/code&gt; option to &lt;code&gt;jq&lt;/code&gt; causes it to return just the raw string, without quote marks.&lt;/p&gt;

&lt;p&gt;Datasette's &lt;code&gt;/-/databases.json&lt;/code&gt; introspection URL reveals the hashes of the currently deployed database. Here's how to pull the currently deployed hash:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ curl -s https://fara.datasettes.com/-/databases.json | jq '.[0].hash' -r
a6c0ab26589bde0d225c5a45044e0adbfa3840b95fbb263d01fd8fb0d2460ed5&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If those two hashes differ then we should deploy the new database.&lt;/p&gt;

&lt;p&gt;GitHub Actions have a &lt;a href="https://help.github.com/en/actions/automating-your-workflow-with-github-actions/development-tools-for-github-actions#set-an-output-parameter-set-output"&gt;slightly bizarre mechanism&lt;/a&gt; for defining "output variables" for steps, which can then be used to conditionally run further steps.&lt;/p&gt;

&lt;p&gt;Here's the step that sets those variables, followed by the step that conditionally installs the Google Cloud CLI tools using &lt;a href="https://github.com/GoogleCloudPlatform/github-actions/tree/master/setup-gcloud"&gt;their official action&lt;/a&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Set variables to decide if we should deploy
  id: decide_variables
  run: |-
    echo "##[set-output name=latest;]$(datasette inspect fara.db | jq '.fara.hash' -r)"
    echo "##[set-output name=deployed;]$(curl -s https://fara.datasettes.com/-/databases.json | jq '.[0].hash' -r)"
- name: Set up Cloud Run
  if: steps.decide_variables.outputs.latest != steps.decide_variables.outputs.deployed
  uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
  with:
    version: '275.0.0'
    service_account_email: ${{ secrets.GCP_SA_EMAIL }}
    service_account_key: ${{ secrets.GCP_SA_KEY }}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Having installed the Google Cloud tools, I can deploy my database using Datasette:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Deploy to Cloud Run
  if: steps.decide_variables.outputs.latest != steps.decide_variables.outputs.deployed
  run: |-
    gcloud components install beta
    gcloud config set run/region us-central1
    gcloud config set project datasette-222320
    datasette publish cloudrun fara.db --service fara-history -m metadata.json&lt;/code&gt;&lt;/pre&gt;

&lt;p id="google-cloud-service-key"&gt;This was by far the hardest part to figure out.&lt;/p&gt;

&lt;p&gt;First, I needed to create a Google Cloud &lt;a href="https://cloud.google.com/iam/docs/service-accounts"&gt;service account&lt;/a&gt; with an accompanying service key.&lt;/p&gt;

&lt;p&gt;I tried and failed to do this using the CLI, so I switched to their web console following &lt;a href="https://cloud.google.com/iam/docs/creating-managing-service-accounts"&gt;these&lt;/a&gt; and then &lt;a href="https://cloud.google.com/iam/docs/creating-managing-service-account-keys"&gt;these&lt;/a&gt; instructions.&lt;/p&gt;

&lt;p&gt;Having downloaded the key JSON file, I converted it to base64 and pasted it into a GitHub Actions secret (hidden away in the repository settings area) called &lt;code&gt;GCP_SA_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;cat ~/Downloads/datasette-222320-2ad02afe6d82.json \
    | base64 | pbcopy&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The service account needed permissions in order to run a build through Cloud Build and then deploy the result through Cloud Run. I spent a bunch of time trying out different combinations and eventually gave up and gave the account "Editor" permissions across my entire project. This is bad. I am hoping someone can help me understand what the correct narrow set of permissions are, and how to apply them.&lt;/p&gt;

&lt;p&gt;It also took me a while to figure out that I needed to run these three commands before I could deploy to my project. The first one installs the Cloud Run tools, the second set up some required configuration:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;gcloud components install beta
gcloud config set run/region us-central1
gcloud config set project datasette-222320&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;But... having done all of the above, the following command run from an action successfully deploys the site!&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;datasette publish cloudrun fara.db \
    --service fara-history -m metadata.json&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;DNS&lt;/h3&gt;

&lt;p&gt;Google Cloud Run deployments come with extremely ugly default URLs. For this project, that URL is &lt;code&gt;https://fara-history-j7hipcg4aq-uc.a.run.app/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I wanted something nicer. I own &lt;code&gt;datasettes.com&lt;/code&gt; and manage the DNS via Cloudflare, which means I can point subdomains at Cloud Run instances.&lt;/p&gt;

&lt;p&gt;This is a two-step process&lt;/p&gt;

&lt;ol&gt;&lt;li&gt;I set &lt;code&gt;fara.datasettes.com&lt;/code&gt; as a DNS-only (no proxying) CNAME for &lt;code&gt;ghs.googlehosted.com&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;In the Google Cloud Console I used Cloud Run -&amp;gt; Manage Custom Domains (a button in the header) -&amp;gt; Add Mapping to specify that &lt;code&gt;fara.datasettes.com&lt;/code&gt; should map to my &lt;code&gt;fara-history&lt;/code&gt; service (the &lt;code&gt;--service&lt;/code&gt; argument from &lt;code&gt;datasette publish&lt;/code&gt; earlier).&lt;/li&gt;&lt;/ol&gt;

&lt;p&gt;I had previously &lt;a href="https://support.google.com/webmasters/answer/9008080?hl=en"&gt;verified my domain ownership&lt;/a&gt; - I forget quite how I did it. Domains purchased through &lt;a href="https://domains.google/"&gt;Google Domains&lt;/a&gt; get to skip this step.&lt;/p&gt;

&lt;h3&gt;Next steps&lt;/h3&gt;

&lt;p&gt;This was a lot of fiddling around. I'm hoping that by writing this up in detail I'll be able to get this working much faster next time.&lt;/p&gt;

&lt;p&gt;I think this model - GitHub Actions that pull data, build a database and deploy to Cloud Run using &lt;code&gt;datasette publish&lt;/code&gt; - is incredibly promising. The end result should be an API that costs cents-to-dollars a month to operate thanks to Cloud Run's scale-to-zero architecture. And hopefully by publishing this all on GitHub it will be as easy as possible for other people to duplicate it for their own projects.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/data-journalism"&gt;data-journalism&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cloudrun"&gt;cloudrun&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git-scraping"&gt;git-scraping&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="data-journalism"/><category term="github"/><category term="projects"/><category term="datasette"/><category term="cloudrun"/><category term="github-actions"/><category term="git-scraping"/></entry><entry><title>GitHub Actions ci.yml for deno</title><link href="https://simonwillison.net/2019/Dec/18/github-actions-ciyml-deno/#atom-tag" rel="alternate"/><published>2019-12-18T08:49:40+00:00</published><updated>2019-12-18T08:49:40+00:00</updated><id>https://simonwillison.net/2019/Dec/18/github-actions-ciyml-deno/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/denoland/deno/blob/c93ae0b05a4c4fe5b43a9bd2b6430637b17979d0/.github/workflows/ci.yml"&gt;GitHub Actions ci.yml for deno&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Spotted this today: it’s one of the cleanest examples I’ve seen of a complex CI configuration for GitHub Actions, testing, linting, benchmarking and building Ryan Dahl’s Deno JavaScript runtime.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ryan-dahl"&gt;ryan-dahl&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/deno"&gt;deno&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="github"/><category term="ryan-dahl"/><category term="github-actions"/><category term="deno"/></entry><entry><title>Continuous Integration with Travis CI - ZEIT Documentation</title><link href="https://simonwillison.net/2018/Jun/1/zeit-with-travis-ci/#atom-tag" rel="alternate"/><published>2018-06-01T17:21:50+00:00</published><updated>2018-06-01T17:21:50+00:00</updated><id>https://simonwillison.net/2018/Jun/1/zeit-with-travis-ci/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://zeit.co/docs/continuous-integration/travis?utm_source=twitter&amp;amp;utm_medium=social&amp;amp;utm_campaign=travis_ci_guide"&gt;Continuous Integration with Travis CI - ZEIT Documentation&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
One of the neat things about Zeit Now is that since deployments are unlimited and are automatically assigned a unique URL you can set up a continuous integration system like Travis to deploy a brand new copy of every commit or every pull request. This documentation also shows how to have commits to master automatically aliased to a known URL. I have quite a few Datasette projects that are deployed automatically to Now by Travis and the pattern seems to be working great so far.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/zeit-now"&gt;zeit-now&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/travis"&gt;travis&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="zeit-now"/><category term="travis"/></entry><entry><title>Porting my blog to Python 3</title><link href="https://simonwillison.net/2017/Oct/21/python3/#atom-tag" rel="alternate"/><published>2017-10-21T22:22:40+00:00</published><updated>2017-10-21T22:22:40+00:00</updated><id>https://simonwillison.net/2017/Oct/21/python3/#atom-tag</id><summary type="html">
    &lt;p&gt;This blog is now running on Python 3! Admittedly this is nearly nine years after &lt;a href="https://www.python.org/download/releases/3.0/"&gt;the first release of Python 3.0&lt;/a&gt;, but it’s the first Python 3 project I’ve deployed myself so I’m pretty excited about it.&lt;/p&gt;
&lt;p&gt;Library authors like to use &lt;a href="https://pypi.python.org/pypi/six"&gt;six&lt;/a&gt; to allow them to write code that supports both Python 2 and Python 3 at the same time… but my blog isn’t a library, so I used the &lt;a href="https://docs.python.org/3/library/2to3.html"&gt;2to3 conversion tool&lt;/a&gt; that ships with Python instead.&lt;/p&gt;
&lt;p&gt;And… it worked pretty well! I ran the following command from my project’s root directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2to3 -w -n blog/ config/ redirects/ feedstats/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-w&lt;/code&gt; option causes the files to be over-written in place. Since everything is already in git, there was no reason to have 2to3 show my a diff without applying it. Likewise, the &lt;code&gt;-n&lt;/code&gt; option tells 2to3 not to bother saving backups of the files it modifies.&lt;/p&gt;
&lt;p&gt;Here’s &lt;a href="https://github.com/simonw/simonwillisonblog/commit/615efeba55c0c32a8147bda49e207a7a52ddb674"&gt;the initial commit&lt;/a&gt; containing mostly the 2to3 changes.&lt;/p&gt;
&lt;p&gt;Next step: run the tests! My test suite may be very thin, but it does at least check that the app can run its migrations, start up and serve a few basic pages without errors. One of my migrations was failing due to rogue bytestrings but that was &lt;a href="https://github.com/simonw/simonwillisonblog/commit/f00224e7375098bb500b56b35c6f40dbc4955abc#diff-15a9a47c9ee1f93f3a07a4dbe0cf4214"&gt;an easy fix&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;At this point I started to lean heavily on my &lt;a href="https://simonwillison.net/2017/Oct/17/free-continuous-deployment/"&gt;continuous integration setup built on Travis CI&lt;/a&gt;. All of my Python 3 work took place &lt;a href="https://github.com/simonw/simonwillisonblog/tree/python3"&gt;in a branch&lt;/a&gt;, and all it took was a &lt;a href="https://github.com/simonw/simonwillisonblog/commit/f00224e7375098bb500b56b35c6f40dbc4955abc#diff-354f30a63fb0907d4ad57269548329e3"&gt;one line change to my .travis.yml&lt;/a&gt; for Travis to start running the tests for that branch using Python 3.&lt;/p&gt;
&lt;p&gt;With the basic tests working, I made my first deploy to my Heroku staging instance - after first &lt;a href="https://github.com/simonw/simonwillisonblog/commit/54b31e98e35031afbbf8c18f3c2446a0af8b5c65"&gt;modifying my Heroku runtime.txt&lt;/a&gt; to tell it to use Python 3.6.2. My staging environment allowed me to sanity check that everything would work OK when deployed to Heroku.&lt;/p&gt;
&lt;p&gt;At this point I got a bit lazy. The responsible thing to do would have been extensive manual testing plus systematic unit test coverage of core functionality. My blog is hardly a critical piece of infrastructure though, so I went with the faster option: put it all live and &lt;a href="https://simonwillison.net/2017/Oct/17/free-continuous-deployment/#Step_4_Monitor_errors_with_Sentry_75"&gt;use Sentry to see if anything breaks&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This is where Heroku’s ability to deploy a specific branch came in handy: one click to deploy my python3 branch, keep an eye on Sentry (via push notifications from &lt;a href="https://simonwillison.net/2017/Oct/17/free-continuous-deployment/#Step_5_Hook_it_all_together_with_Slack_97"&gt;my private slack channel&lt;/a&gt;) and then one click to deploy my master branch again for an instant rollback in case of errors. Which I had to do instantly, because it turned out I had stored some data in Django’s cache using Python 2 pickle and was trying to read it back out again using Python 3.&lt;/p&gt;
&lt;p&gt;I fixed that by &lt;a href="https://github.com/simonw/simonwillisonblog/commit/41f7a112721ec5772ad189c4293da081291a604a"&gt;bumping my cache VERSION setting&lt;/a&gt; and deployed again. This deploy lasted a few minute longer before Sentry started to fill up with encoding errors and I rolled it back again.&lt;/p&gt;
&lt;p&gt;The single biggest difference between Python 2 and Python 3 is &lt;a href="https://docs.python.org/3.0/whatsnew/3.0.html#text-vs-data-instead-of-unicode-vs-8-bit"&gt;how strings are handled&lt;/a&gt;. Python 3 strings are unicode sequences. Learning to live in a world where strings are all unicode and byte strings are the rare, deliberate exceptions takes some getting used to.&lt;/p&gt;
&lt;p&gt;The key challenge for my blog actually came from &lt;a href="https://github.com/simonw/simonwillisonblog/blob/45d7acd56af475119d2738e736d9b4cb19a9e8eb/blog/templatetags/entry_tags.py"&gt;my custom markup handling template tags&lt;/a&gt;. 15 years ago &lt;a href="https://simonwillison.net/2002/Jun/16/myFirstXhtmlMindBomb/"&gt;I made the decision&lt;/a&gt; to &lt;a href="https://simonwillison.net/2003/Jan/6/xhtmlIsJustFine/"&gt;store all of my blog entries&lt;/a&gt; as valid XHTML fragments. This meant I could use XML processors - back then in PHP, today &lt;a href="https://docs.python.org/3/library/xml.etree.elementtree.html"&gt;Python’s ElementTree&lt;/a&gt; - to perform various transformations on my content.&lt;/p&gt;
&lt;p&gt;ElementTree in Python 2 can only consume bytestrings. In Python 3 it expects unicode strings. Cleaning this up took a while, eventually inspiring me to &lt;a href="https://github.com/simonw/simonwillisonblog/commit/7295cddd1a6ab2c7bc6fcf3da410ab6ea0954791"&gt;refactor my custom template tags completely&lt;/a&gt;. In the process I realized that my blog templates were mostly written back before Django’s template language implemented autoescape (&lt;a href="https://simonwillison.net/2008/Jul/22/alpha/"&gt;in Django 1.0&lt;/a&gt;), so my code was littered with unnecessary &lt;code&gt;|escape&lt;/code&gt; and &lt;code&gt;|safe&lt;/code&gt; filters. Those are all gone now.&lt;/p&gt;
&lt;p&gt;Sentry lets you mark an exception as “resolved” when you think you’ve fixed it - if it occurs again after that it will be re-reported to your Slack channel and added back to the Sentry list of unresolved issues. Once Sentry was clear (especially given Googlebot had crawled my older pages) I could be pretty confident there were no more critical 500-causing errors.&lt;/p&gt;
&lt;p&gt;That left logic errors, of which only one has cropped up so far: the “zero years ago” bug. Entries on my homepage include a relative date representation, e.g. “three days ago”. Python 3 &lt;a href="https://www.python.org/dev/peps/pep-0238/"&gt;changed how the divison operator works on integers&lt;/a&gt; - &lt;code&gt;3 / 2 == 1.5&lt;/code&gt; where in Python 2 it gets truncated to &lt;code&gt;1&lt;/code&gt;. As a result, every entry on my homepage showed “zero years ago”. Thankfully this was &lt;a href="https://github.com/simonw/simonwillisonblog/commit/d6e6eeb93ac02aa33c59b151f2a20e26d41f18b1"&gt;a one-line fix&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;All in all this process was much less painful than I expected. It took me longer to write this blog entry than it did to actually make the conversion (thanks to 2to3 doing most of the tedious work), and the combination of Travis CI, Sentry and Heroku allowed me to ship aggressively with the knowledge that I could promptly identify and resolve any issues that came up.&lt;/p&gt;
&lt;p&gt;Next upgrade: &lt;a href="https://www.djangoproject.com/weblog/2017/oct/16/django-20-beta-1-released/"&gt;Django 2.0&lt;/a&gt;!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python3"&gt;python3&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/heroku"&gt;heroku&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/travis"&gt;travis&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sentry"&gt;sentry&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-integration"/><category term="python"/><category term="python3"/><category term="heroku"/><category term="travis"/><category term="sentry"/></entry><entry><title>How to set up world-class continuous deployment using free hosted tools</title><link href="https://simonwillison.net/2017/Oct/17/free-continuous-deployment/#atom-tag" rel="alternate"/><published>2017-10-17T13:32:49+00:00</published><updated>2017-10-17T13:32:49+00:00</updated><id>https://simonwillison.net/2017/Oct/17/free-continuous-deployment/#atom-tag</id><summary type="html">
    &lt;p&gt;I’m going to describe a way to put together a world-class continuous deployment infrastructure for your side-project without spending any money.&lt;/p&gt;
&lt;p&gt;With &lt;a href="https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff"&gt;continuous deployment&lt;/a&gt; every code commit is tested against an automated test suite. If the tests pass it gets deployed directly to the production environment! How’s that for an incentive to write comprehensive tests?&lt;/p&gt;
&lt;p&gt;Each of the tools I’m using offers a free tier which is easily enough to handle most side-projects. And once you outgrow those free plans, you can solve those limitations in exchange for money!&lt;/p&gt;
&lt;p&gt;Here’s the magic combination:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/"&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://travis-ci.org/"&gt;Travis CI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://heroku.com/"&gt;Heroku&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sentry.io/"&gt;Sentry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://slack.com/"&gt;Slack&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;a id="Step_one_Publish_some_code_to_GitHub_with_some_tests_16"&gt;&lt;/a&gt;Step one: Publish some code to GitHub with some tests&lt;/h2&gt;
&lt;p&gt;I’ll be using the &lt;a href="https://github.com/simonw/simonwillisonblog"&gt;code for my blog&lt;/a&gt; as an example. It’s a classic Django application, with a small (OK, tiny) suite of unit tests. The tests are run using the standard Django &lt;code&gt;./manage.py test&lt;/code&gt; command.&lt;/p&gt;
&lt;p&gt;Writing a Django application with tests is outside the scope of this article. Thankfully the official Django tutorial &lt;a href="https://docs.djangoproject.com/en/1.11/intro/tutorial05/"&gt;covers testing in some detail&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;&lt;a id="Step_two_Hook_up_Travis_CI_22"&gt;&lt;/a&gt;Step two: Hook up Travis CI&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://travis-ci.org/"&gt;Travis CI&lt;/a&gt; is an outstanding hosted platform for continuous integration. Given a small configuration file it can check out code from GitHub, set up an isolated test environment (including hefty dependencies like a PostgreSQL database server, Elasticsearch, Redis etc), run your test suite and report the resulting pass/fail grade back to GitHub.&lt;/p&gt;
&lt;p&gt;It’s free for publicly hosted GitHub projects. If you want to test code in a private repository you’ll have to pay them some money.&lt;/p&gt;
&lt;p&gt;Here’s &lt;a href="https://github.com/simonw/simonwillisonblog/blob/a5c2d2549f26dd2d75cbf863c8b36d617092c2a1/.travis.yml"&gt;my .travis.yml configuration file&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;language: python

python:
  - 2.7

services: postgresql

addons:
  postgresql: &amp;quot;9.6&amp;quot;

install:
  - pip install -r requirements.txt

before_script:
  - psql -c &amp;quot;CREATE DATABASE travisci;&amp;quot; -U postgres
  - python manage.py migrate --noinput
  - python manage.py collectstatic

script:
  - python manage.py test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here’s the resulting &lt;a href="https://travis-ci.org/simonw/simonwillisonblog"&gt;Travis CI dashboard&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The integration of Travis with GitHub runs &lt;em&gt;deep&lt;/em&gt;. Once you’ve set up Travis, it will automatically test every push to every branch - driven by GitHub webhooks, so test runs are set off almost instantly. Travis will then report the test results back to GitHub, where they’ll show up in a bunch of different places -  including these pleasing green ticks on &lt;a href="https://github.com/simonw/simonwillisonblog/branches"&gt;the branches page&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img style="width: 100%" src="https://static.simonwillison.net/static/2017/github-branches-with-ci-small.png" alt="GitHub branches page showing CI results" /&gt;&lt;/p&gt;
&lt;p&gt;Travis will also run tests against any &lt;a href="https://github.com/simonw/simonwillisonblog/pull/3"&gt;open pull requests&lt;/a&gt;. This is a great incentive to build new features in a pull request even if you aren’t using them for code review:&lt;/p&gt;
&lt;p&gt;&lt;img style="width: 100%" src="https://static.simonwillison.net/static/2017/github-pull-request-with-ci-small.png" alt="GitHub pull request showing CI results" /&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://circleci.com/"&gt;Circle CI&lt;/a&gt; deserves a mention as an alternative to Travis. The two are close competitors and offer very similar feature sets, and Circle CI's free plan allows up to 1,500 build minutes of private repositories per month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 25th July 2020&lt;/strong&gt;: I've started using GitHub Actions for most of my projects now - see my &lt;a href="https://simonwillison.net/tags/githubactions/"&gt;githubactions&lt;/a&gt; tag.&lt;/p&gt;

&lt;h2&gt;&lt;a id="Step_3_Deploy_to_Heroku_and_turn_on_continuous_deployment_61"&gt;&lt;/a&gt;Step 3: Deploy to Heroku and turn on continuous deployment&lt;/h2&gt;
&lt;p&gt;I’m a big fan of &lt;a href="https://heroku.com/"&gt;Heroku&lt;/a&gt; for side projects, because it means not having to worry about ongoing server-maintenance. I’ve lost several side-projects to &lt;a href="https://blog.heroku.com/archives/2011/6/28/the_new_heroku_4_erosion_resistance_explicit_contracts/"&gt;entropy and software erosion&lt;/a&gt; - getting an initial VPS set up may be pretty simple, but a year later security patches need applying and the OS needs upgrading and the log files have filled up the disk and you’ve forgotten how you set everything up in the first place…&lt;/p&gt;
&lt;p&gt;It turns out Heroku has basic support for continuous deployment baked in, and it’s trivially easy to set up. You can tell Heroku to deploy on every commit to GitHub, and then if you’ve attached a CI service like Travis that reports build health back you can check the box for “Wait for CI to pass before deploy”:&lt;/p&gt;
&lt;p&gt;&lt;img style="width: 100%" src="https://static.simonwillison.net/static/2017/heroku-deploy-settings-small.png" alt="Heroku deployment settings for continuous deployment" /&gt;&lt;/p&gt;
&lt;p&gt;Since small dynos on Heroku are free, you can even set up a separate Heroku app as a staging environment. I started my continuous integration adventure just deploying automatically to my staging instance, then switched over to deploying to production once I gained some confidence in how it all fitted together.&lt;/p&gt;
&lt;p&gt;If you’re using continuous deployment with Heroku and Django, it’s a good idea to set up Heroku to automatically run your migrations for every deploy - otherwise you might merge a pull request with a model change and forget to run the migrations before the deploy goes out. You can do that using Heroku’s &lt;a href="https://devcenter.heroku.com/articles/release-phase"&gt;release phase&lt;/a&gt; feature, by adding the line &lt;code&gt;release: python manage.py migrate --noinput&lt;/code&gt; to your Heroku &lt;code&gt;Procfile&lt;/code&gt; (&lt;a href="https://github.com/simonw/simonwillisonblog/blob/81f7e2ba19b84f572e8a546bcc28bbfb1e211eb6/Procfile"&gt;here’s mine&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Once you go beyond Heroku’s free tier things get much more powerful: &lt;a href="https://www.heroku.com/flow"&gt;Heroku Flow&lt;/a&gt; combines pipelines, review apps and their own CI solution to provide a comprehensive solution for much larger teams.&lt;/p&gt;
&lt;h2&gt;&lt;a id="Step_4_Monitor_errors_with_Sentry_75"&gt;&lt;/a&gt;Step 4: Monitor errors with Sentry&lt;/h2&gt;
&lt;p&gt;If you’re going to move fast and break things, you need to know when things have broken. &lt;a href="https://sentry.io/"&gt;Sentry&lt;/a&gt; is a fantastic tool for collecting exceptions, aggregating them and spotting when something new crops up. It’s open source so you can host it yourself, but they also offer a robust hosted version with a free plan that can track up to 10,000 errors a month.&lt;/p&gt;
&lt;p&gt;My favourite feature of Sentry is that it gives each exception it sees a “signature” based on a MD5 hash of its traceback. This means it can tell if errors are the same underlying issue or something different, and can hence de-dupe them and only alert you the first time it spots an error it has not seen before.&lt;/p&gt;
&lt;p&gt;&lt;img style="width: 100%" src="https://static.simonwillison.net/static/2017/sentry-small.png" alt="Notifications from Travis CI and GitHub in Slack" /&gt;&lt;/p&gt;
&lt;p&gt;Sentry has integrations for most modern languages, but it’s particularly easy to use with Django. Just install &lt;a href="https://pypi.python.org/pypi/raven"&gt;raven&lt;/a&gt; and add few extra lines to your &lt;a href="http://settings.py"&gt;settings.py&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SENTRY_DSN = os.environ.get('SENTRY_DSN')
if SENTRY_DSN:
    INSTALLED_APPS += (
        'raven.contrib.django.raven_compat',
    )
    RAVEN_CONFIG = {
        'dsn': SENTRY_DSN,
        'release': os.environ.get('HEROKU_SLUG_COMMIT', ''),
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here I’m using the Heroku pattern of &lt;a href="https://devcenter.heroku.com/articles/config-vars"&gt;keeping configuration in environment variables&lt;/a&gt;. &lt;code&gt;SENTRY_DSN&lt;/code&gt; is provided by Sentry when you create your project there - you just have to add it as a Heroku config variable.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;HEROKU_SLUG_COMMIT&lt;/code&gt; line causes the currently deployed git commit hash to be fed to Sentry so that it knows what version of your code was running when it reports an error. To enable that variable, you’ll need to &lt;a href="https://devcenter.heroku.com/articles/dyno-metadata"&gt;enable Dyno Metadata&lt;/a&gt; by running &lt;code&gt;heroku labs:enable runtime-dyno-metadata&lt;/code&gt; against your application.&lt;/p&gt;
&lt;h2&gt;&lt;a id="Step_5_Hook_it_all_together_with_Slack_97"&gt;&lt;/a&gt;Step 5: Hook it all together with Slack&lt;/h2&gt;
&lt;p&gt;Would you like a push notification to your phone every time your site gets code committed / the tests pass or fail / a deploy goes out / a new error is detected? All of the above tools can report such things to &lt;a href="https://slack.com/"&gt;Slack&lt;/a&gt;, and Slack’s free plan is easily enough to collect all of these notifications and push them to your phone via the free Slack &lt;a href="https://slack.com/downloads/ios"&gt;iOS&lt;/a&gt; or &lt;a href="https://slack.com/downloads/android"&gt;Android&lt;/a&gt; apps.&lt;/p&gt;
&lt;p&gt;&lt;img style="width: 100%" src="https://static.simonwillison.net/static/2017/slack-github-ci-small.png" alt="Notifications from Travis CI and GitHub in Slack" /&gt;&lt;/p&gt;
&lt;p&gt;Here are instructions for setting up Slack with &lt;a href="https://get.slack.help/hc/en-us/articles/232289568-Use-GitHub-with-Slack"&gt;GitHub&lt;/a&gt;, &lt;a href="https://docs.travis-ci.com/user/notifications/#Configuring-slack-notifications"&gt;Travis CI&lt;/a&gt;, &lt;a href="https://slack.com/apps/A0F7VRF7E-heroku"&gt;Heroku&lt;/a&gt; and &lt;a href="https://slack.com/apps/A0F814BEV-sentry"&gt;Sentry&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;&lt;a id="Need_more_Pay_for_it_105"&gt;&lt;/a&gt;Need more? Pay for it!&lt;/h2&gt;
&lt;p&gt;Having run much of this kind of infrastructure myself in the past I for one am delighted by the idea of outsourcing it, especially when the hosted options are of such high quality.&lt;/p&gt;
&lt;p&gt;Each of these tools offers a free tier which is generous enough to work great for small side projects. As you start scaling up, you can start paying for them - that’s why they gave you a free tier in the first place.&lt;/p&gt;

&lt;p&gt;Comments or suggestions? Join &lt;a href="https://news.ycombinator.com/item?id=15490935"&gt;this thread on Hacker News&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/heroku"&gt;heroku&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/slack"&gt;slack&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/travis"&gt;travis&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sentry"&gt;sentry&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="django"/><category term="github"/><category term="postgresql"/><category term="testing"/><category term="heroku"/><category term="slack"/><category term="travis"/><category term="sentry"/></entry><entry><title>What are good and easy practices for frequent web deployments?</title><link href="https://simonwillison.net/2013/Jan/8/what-are-good-and/#atom-tag" rel="alternate"/><published>2013-01-08T10:32:00+00:00</published><updated>2013-01-08T10:32:00+00:00</updated><id>https://simonwillison.net/2013/Jan/8/what-are-good-and/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;em&gt;My answer to &lt;a href="https://www.quora.com/What-are-good-and-easy-practices-for-frequent-web-deployments/answer/Simon-Willison"&gt;What are good and easy practices for frequent web deployments?&lt;/a&gt; on Quora&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At Lanyrd we use a combination of Fabric to drive our deploy scripts, git to get the code on to the servers, puppet for configuration management and Jenkins to run continuous integration tests and provide a "deploy the site" button.&lt;/p&gt;

&lt;p&gt;Here are a few important techniques I've learned:&lt;/p&gt;

&lt;ul&gt;&lt;li&gt;Use symlink switching to keep the previous version of the code around, so you can switch back in the case of problems (that said, we've never actually used this capability - but its nice for atomic deploys as well)&lt;/li&gt;&lt;li&gt;Have your build script rename your static asset files (CSS/JS/etc) to include part of the md5 hash of the file contents in their filename. This means you can upload them to your static host provider (we use S3) before you run a deploy, guaranteeing that freshly deployed templates will point to the right files. It also keeps the older versions around in case you need to roll back.&lt;/li&gt;&lt;li&gt;Having one button that deploys the site is invaluable&lt;/li&gt;&lt;li&gt;Deploys need to be almost "free" in terms of impact on site performance - if it doesn't cost anything to deploy the site people will be freely able to deploy often and push out small fixes, which is good for the health of your codebase&lt;/li&gt;&lt;li&gt;Get new engineers to deploy on the first day! Doing so forces you/them to get a full development and deployment environment up and running for them on day one, which means that they can start doing real work on day two.&lt;/li&gt;&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/startups"&gt;startups&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/quora"&gt;quora&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/lanyrd"&gt;lanyrd&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/devops"&gt;devops&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-integration"/><category term="startups"/><category term="quora"/><category term="lanyrd"/><category term="devops"/></entry><entry><title>Fabric factory</title><link href="https://simonwillison.net/2009/Sep/21/fabricfactory/#atom-tag" rel="alternate"/><published>2009-09-21T18:35:12+00:00</published><updated>2009-09-21T18:35:12+00:00</updated><id>https://simonwillison.net/2009/Sep/21/fabricfactory/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://yml-blog.blogspot.com/2009/09/fabric-factory.html"&gt;Fabric factory&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Promising looking continuous integration server written in Django, which uses Fabric scripts to define actions.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/fabric"&gt;fabric&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/fabricfactory"&gt;fabricfactory&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="django"/><category term="fabric"/><category term="fabricfactory"/><category term="python"/><category term="testing"/></entry><entry><title>Localbuilder</title><link href="https://simonwillison.net/2009/Jan/14/localbuilder/#atom-tag" rel="alternate"/><published>2009-01-14T22:57:59+00:00</published><updated>2009-01-14T22:57:59+00:00</updated><id>https://simonwillison.net/2009/Jan/14/localbuilder/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://morethanseven.net/2009/01/14/localbuilder-github/"&gt;Localbuilder&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Gareth Rushgrove’s neat little Python continuous integration tool—it watches a directory for changes, then runs a command when it spots any.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gareth-rushgrove"&gt;gareth-rushgrove&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/localbuilder"&gt;localbuilder&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;&lt;/p&gt;



</summary><category term="continuous-integration"/><category term="gareth-rushgrove"/><category term="localbuilder"/><category term="python"/><category term="testing"/></entry></feed>